Refactors calendar project structure and build configuration
Consolidates V2 codebase into main project directory Updates build script to support simplified entry points Removes redundant files and cleans up project organization Simplifies module imports and entry points for calendar application
This commit is contained in:
parent
9f360237cf
commit
863b433eba
200 changed files with 2331 additions and 16193 deletions
|
|
@ -1,744 +0,0 @@
|
|||
// All-day row height management and animations
|
||||
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { ALL_DAY_CONSTANTS } from '../configurations/CalendarConfig';
|
||||
import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
|
||||
import { AllDayLayoutEngine, IEventLayout } from '../utils/AllDayLayoutEngine';
|
||||
import { IColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
|
||||
import { IColumnDataSource } from '../types/ColumnDataSource';
|
||||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { CalendarEventType } from '../types/BookingTypes';
|
||||
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
|
||||
import {
|
||||
IDragMouseEnterHeaderEventPayload,
|
||||
IDragMouseEnterColumnEventPayload,
|
||||
IDragStartEventPayload,
|
||||
IDragMoveEventPayload,
|
||||
IDragEndEventPayload,
|
||||
IDragColumnChangeEventPayload,
|
||||
IHeaderReadyEventPayload
|
||||
} from '../types/EventTypes';
|
||||
import { IDragOffset, IMousePosition } from '../types/DragDropTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { EventManager } from './EventManager';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { EventId } from '../types/EventId';
|
||||
|
||||
/**
|
||||
* AllDayManager - Handles all-day row height animations and management
|
||||
* Uses AllDayLayoutEngine for all overlap detection and layout calculation
|
||||
*/
|
||||
export class AllDayManager {
|
||||
private allDayEventRenderer: AllDayEventRenderer;
|
||||
private eventManager: EventManager;
|
||||
private dateService: DateService;
|
||||
private dataSource: IColumnDataSource;
|
||||
|
||||
private layoutEngine: AllDayLayoutEngine | null = null;
|
||||
|
||||
// State tracking for layout calculation
|
||||
private currentAllDayEvents: ICalendarEvent[] = [];
|
||||
private currentColumns: IColumnBounds[] = [];
|
||||
|
||||
// Expand/collapse state
|
||||
private isExpanded: boolean = false;
|
||||
private actualRowCount: number = 0;
|
||||
|
||||
|
||||
constructor(
|
||||
eventManager: EventManager,
|
||||
allDayEventRenderer: AllDayEventRenderer,
|
||||
dateService: DateService,
|
||||
dataSource: IColumnDataSource
|
||||
) {
|
||||
this.eventManager = eventManager;
|
||||
this.allDayEventRenderer = allDayEventRenderer;
|
||||
this.dateService = dateService;
|
||||
this.dataSource = dataSource;
|
||||
|
||||
// Sync CSS variable with TypeScript constant to ensure consistency
|
||||
document.documentElement.style.setProperty('--single-row-height', `${ALL_DAY_CONSTANTS.EVENT_HEIGHT}px`);
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for drag conversions
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
eventBus.on('drag:mouseenter-header', (event) => {
|
||||
const payload = (event as CustomEvent<IDragMouseEnterHeaderEventPayload>).detail;
|
||||
|
||||
if (payload.draggedClone.hasAttribute('data-allday'))
|
||||
return;
|
||||
|
||||
console.log('🔄 AllDayManager: Received drag:mouseenter-header', {
|
||||
targetDate: payload.targetColumn,
|
||||
originalElementId: payload.originalElement?.dataset?.eventId,
|
||||
originalElementTag: payload.originalElement?.tagName
|
||||
});
|
||||
|
||||
this.handleConvertToAllDay(payload);
|
||||
});
|
||||
|
||||
eventBus.on('drag:mouseleave-header', (event) => {
|
||||
const { originalElement, cloneElement } = (event as CustomEvent).detail;
|
||||
|
||||
console.log('🚪 AllDayManager: Received drag:mouseleave-header', {
|
||||
originalElementId: originalElement?.dataset?.eventId
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// Listen for drag operations on all-day events
|
||||
eventBus.on('drag:start', (event) => {
|
||||
let payload: IDragStartEventPayload = (event as CustomEvent<IDragStartEventPayload>).detail;
|
||||
|
||||
if (!payload.draggedClone?.hasAttribute('data-allday')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.allDayEventRenderer.handleDragStart(payload);
|
||||
});
|
||||
|
||||
eventBus.on('drag:column-change', (event) => {
|
||||
let payload: IDragColumnChangeEventPayload = (event as CustomEvent<IDragColumnChangeEventPayload>).detail;
|
||||
|
||||
if (!payload.draggedClone?.hasAttribute('data-allday')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleColumnChange(payload);
|
||||
});
|
||||
|
||||
eventBus.on('drag:end', (event) => {
|
||||
let dragEndPayload: IDragEndEventPayload = (event as CustomEvent<IDragEndEventPayload>).detail;
|
||||
|
||||
console.log('🎯 AllDayManager: drag:end received', {
|
||||
target: dragEndPayload.target,
|
||||
originalElementTag: dragEndPayload.originalElement?.tagName,
|
||||
hasAllDayAttribute: dragEndPayload.originalElement?.hasAttribute('data-allday'),
|
||||
eventId: dragEndPayload.originalElement?.dataset.eventId
|
||||
});
|
||||
|
||||
// Handle all-day → all-day drops (within header)
|
||||
if (dragEndPayload.target === 'swp-day-header' && dragEndPayload.originalElement?.hasAttribute('data-allday')) {
|
||||
console.log('✅ AllDayManager: Handling all-day → all-day drop');
|
||||
this.handleDragEnd(dragEndPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle timed → all-day conversion (dropped in header)
|
||||
if (dragEndPayload.target === 'swp-day-header' && !dragEndPayload.originalElement?.hasAttribute('data-allday')) {
|
||||
console.log('🔄 AllDayManager: Timed → all-day conversion on drop');
|
||||
this.handleTimedToAllDayDrop(dragEndPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle all-day → timed conversion (dropped in column)
|
||||
if (dragEndPayload.target === 'swp-day-column' && dragEndPayload.originalElement?.hasAttribute('data-allday')) {
|
||||
const eventId = dragEndPayload.originalElement.dataset.eventId;
|
||||
|
||||
console.log('🔄 AllDayManager: All-day → timed conversion', { eventId });
|
||||
|
||||
// Mark for removal (sets data-removing attribute)
|
||||
this.fadeOutAndRemove(dragEndPayload.originalElement);
|
||||
|
||||
// Recalculate layout WITHOUT the removed event to compress gaps
|
||||
const remainingEvents = this.currentAllDayEvents.filter(e => e.id !== eventId);
|
||||
const newLayouts = this.calculateAllDayEventsLayout(remainingEvents, this.currentColumns);
|
||||
|
||||
// Re-render all-day events with compressed layout
|
||||
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
|
||||
|
||||
// NOW animate height with compressed layout
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for drag cancellation to recalculate height
|
||||
eventBus.on('drag:cancelled', (event) => {
|
||||
const { draggedElement, reason } = (event as CustomEvent).detail;
|
||||
|
||||
console.log('🚫 AllDayManager: Drag cancelled', {
|
||||
eventId: draggedElement?.dataset?.eventId,
|
||||
reason
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// Listen for header ready - when dates are populated with period data
|
||||
eventBus.on('header:ready', async (event: Event) => {
|
||||
let headerReadyEventPayload = (event as CustomEvent<IHeaderReadyEventPayload>).detail;
|
||||
|
||||
let startDate = this.dateService.parseISO(headerReadyEventPayload.headerElements.at(0)!.identifier);
|
||||
let endDate = this.dateService.parseISO(headerReadyEventPayload.headerElements.at(-1)!.identifier);
|
||||
|
||||
let events: ICalendarEvent[] = await this.eventManager.getEventsForPeriod(startDate, endDate);
|
||||
// Filter for all-day events
|
||||
const allDayEvents = events.filter(event => event.allDay);
|
||||
|
||||
const layouts = this.calculateAllDayEventsLayout(allDayEvents, headerReadyEventPayload.headerElements);
|
||||
|
||||
this.allDayEventRenderer.renderAllDayEventsForPeriod(layouts);
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
});
|
||||
|
||||
eventBus.on(CoreEvents.VIEW_CHANGED, (event: Event) => {
|
||||
this.allDayEventRenderer.handleViewChanged(event as CustomEvent);
|
||||
});
|
||||
}
|
||||
|
||||
private getAllDayContainer(): HTMLElement | null {
|
||||
return document.querySelector('swp-calendar-header swp-allday-container');
|
||||
}
|
||||
|
||||
private getCalendarHeader(): HTMLElement | null {
|
||||
return document.querySelector('swp-calendar-header');
|
||||
}
|
||||
|
||||
private getHeaderSpacer(): HTMLElement | null {
|
||||
return document.querySelector('swp-header-spacer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read current max row from DOM elements
|
||||
* Excludes events marked as removing (data-removing attribute)
|
||||
*/
|
||||
private getMaxRowFromDOM(): number {
|
||||
const container = this.getAllDayContainer();
|
||||
if (!container) return 0;
|
||||
|
||||
let maxRow = 0;
|
||||
const allDayEvents = container.querySelectorAll('swp-allday-event:not(.max-event-indicator):not([data-removing])');
|
||||
|
||||
allDayEvents.forEach((element: Element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
const row = parseInt(htmlElement.style.gridRow) || 1;
|
||||
maxRow = Math.max(maxRow, row);
|
||||
});
|
||||
|
||||
return maxRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current gridArea for an event from DOM
|
||||
*/
|
||||
private getGridAreaFromDOM(eventId: string): string | null {
|
||||
const container = this.getAllDayContainer();
|
||||
if (!container) return null;
|
||||
|
||||
const element = container.querySelector(`[data-event-id="${eventId}"]`) as HTMLElement;
|
||||
return element?.style.gridArea || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count events in a specific column by reading DOM
|
||||
*/
|
||||
private countEventsInColumnFromDOM(columnIndex: number): number {
|
||||
const container = this.getAllDayContainer();
|
||||
if (!container) return 0;
|
||||
|
||||
let count = 0;
|
||||
const allDayEvents = container.querySelectorAll('swp-allday-event:not(.max-event-indicator)');
|
||||
|
||||
allDayEvents.forEach((element: Element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
const gridColumn = htmlElement.style.gridColumn;
|
||||
|
||||
// Parse "1 / 3" format
|
||||
const match = gridColumn.match(/(\d+)\s*\/\s*(\d+)/);
|
||||
if (match) {
|
||||
const startCol = parseInt(match[1]);
|
||||
const endCol = parseInt(match[2]) - 1; // End is exclusive in CSS
|
||||
|
||||
if (startCol <= columnIndex && endCol >= columnIndex) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate all-day height based on number of rows
|
||||
*/
|
||||
private calculateAllDayHeight(targetRows: number): {
|
||||
targetHeight: number;
|
||||
currentHeight: number;
|
||||
heightDifference: number;
|
||||
} {
|
||||
const root = document.documentElement;
|
||||
const targetHeight = targetRows * ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT;
|
||||
// Read CSS variable directly from style property or default to 0
|
||||
const currentHeightStr = root.style.getPropertyValue('--all-day-row-height') || '0px';
|
||||
const currentHeight = parseInt(currentHeightStr) || 0;
|
||||
const heightDifference = targetHeight - currentHeight;
|
||||
|
||||
return { targetHeight, currentHeight, heightDifference };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check current all-day events and animate to correct height
|
||||
* Reads max row directly from DOM elements
|
||||
*/
|
||||
public checkAndAnimateAllDayHeight(): void {
|
||||
// Read max row directly from DOM
|
||||
const maxRows = this.getMaxRowFromDOM();
|
||||
|
||||
console.log('📊 AllDayManager: Height calculation', {
|
||||
maxRows,
|
||||
isExpanded: this.isExpanded
|
||||
});
|
||||
|
||||
// Store actual row count
|
||||
this.actualRowCount = maxRows;
|
||||
|
||||
// Determine what to display
|
||||
let displayRows = maxRows;
|
||||
|
||||
if (maxRows > ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS) {
|
||||
// Show chevron button
|
||||
this.updateChevronButton(true);
|
||||
|
||||
// Show 4 rows when collapsed (3 events + indicators)
|
||||
if (!this.isExpanded) {
|
||||
|
||||
displayRows = ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS;
|
||||
this.updateOverflowIndicators();
|
||||
|
||||
} else {
|
||||
|
||||
this.clearOverflowIndicators();
|
||||
|
||||
}
|
||||
} else {
|
||||
|
||||
// Hide chevron - not needed
|
||||
this.updateChevronButton(false);
|
||||
this.clearOverflowIndicators();
|
||||
}
|
||||
|
||||
console.log('🎬 AllDayManager: Will animate to', {
|
||||
displayRows,
|
||||
maxRows,
|
||||
willAnimate: displayRows !== this.actualRowCount
|
||||
});
|
||||
|
||||
console.log(`🎯 AllDayManager: Animating to ${displayRows} rows`);
|
||||
|
||||
// Animate to required rows (0 = collapse, >0 = expand)
|
||||
this.animateToRows(displayRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate all-day container to specific number of rows
|
||||
*/
|
||||
public animateToRows(targetRows: number): void {
|
||||
const { targetHeight, currentHeight, heightDifference } = this.calculateAllDayHeight(targetRows);
|
||||
|
||||
if (targetHeight === currentHeight) return; // No animation needed
|
||||
|
||||
console.log(`🎬 All-day height animation: ${currentHeight}px → ${targetHeight}px (${Math.ceil(currentHeight / ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT)} → ${targetRows} rows)`);
|
||||
|
||||
// Get cached elements
|
||||
const calendarHeader = this.getCalendarHeader();
|
||||
const headerSpacer = this.getHeaderSpacer();
|
||||
const allDayContainer = this.getAllDayContainer();
|
||||
|
||||
if (!calendarHeader || !allDayContainer) return;
|
||||
|
||||
// Get current parent height for animation
|
||||
const currentParentHeight = parseFloat(getComputedStyle(calendarHeader).height);
|
||||
const targetParentHeight = currentParentHeight + heightDifference;
|
||||
|
||||
const animations = [
|
||||
calendarHeader.animate([
|
||||
{ height: `${currentParentHeight}px` },
|
||||
{ height: `${targetParentHeight}px` }
|
||||
], {
|
||||
duration: 150,
|
||||
easing: 'ease-out',
|
||||
fill: 'forwards'
|
||||
})
|
||||
];
|
||||
|
||||
// Add spacer animation if spacer exists, but don't use fill: 'forwards'
|
||||
if (headerSpacer) {
|
||||
const root = document.documentElement;
|
||||
const headerHeightStr = root.style.getPropertyValue('--header-height');
|
||||
const headerHeight = parseInt(headerHeightStr);
|
||||
const currentSpacerHeight = headerHeight + currentHeight;
|
||||
const targetSpacerHeight = headerHeight + targetHeight;
|
||||
|
||||
animations.push(
|
||||
headerSpacer.animate([
|
||||
{ height: `${currentSpacerHeight}px` },
|
||||
{ height: `${targetSpacerHeight}px` }
|
||||
], {
|
||||
duration: 150,
|
||||
easing: 'ease-out'
|
||||
// No fill: 'forwards' - let CSS calc() take over after animation
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Update CSS variable after animation
|
||||
Promise.all(animations.map(anim => anim.finished)).then(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--all-day-row-height', `${targetHeight}px`);
|
||||
eventBus.emit('header:height-changed');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculate layout for ALL all-day events using AllDayLayoutEngine
|
||||
* This is the correct method that processes all events together for proper overlap detection
|
||||
*/
|
||||
private calculateAllDayEventsLayout(events: ICalendarEvent[], dayHeaders: IColumnBounds[]): IEventLayout[] {
|
||||
|
||||
// Store current state
|
||||
this.currentAllDayEvents = events;
|
||||
this.currentColumns = dayHeaders;
|
||||
|
||||
// Map IColumnBounds to IColumnInfo structure (identifier + groupId)
|
||||
const columns = dayHeaders.map(column => ({
|
||||
identifier: column.identifier,
|
||||
groupId: column.element.dataset.groupId || column.identifier,
|
||||
data: new Date(), // Not used by AllDayLayoutEngine
|
||||
events: [] // Not used by AllDayLayoutEngine
|
||||
}));
|
||||
|
||||
// Initialize layout engine with column info including groupId
|
||||
let layoutEngine = new AllDayLayoutEngine(columns);
|
||||
|
||||
// Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly
|
||||
return layoutEngine.calculateLayout(events);
|
||||
|
||||
}
|
||||
|
||||
private handleConvertToAllDay(payload: IDragMouseEnterHeaderEventPayload): void {
|
||||
|
||||
let allDayContainer = this.getAllDayContainer();
|
||||
if (!allDayContainer) return;
|
||||
|
||||
// Create SwpAllDayEventElement from ICalendarEvent
|
||||
const allDayElement = SwpAllDayEventElement.fromCalendarEvent(payload.calendarEvent);
|
||||
|
||||
// Apply grid positioning
|
||||
allDayElement.style.gridRow = '1';
|
||||
allDayElement.style.gridColumn = payload.targetColumn.index.toString();
|
||||
|
||||
// Remove old swp-event clone
|
||||
payload.draggedClone.remove();
|
||||
|
||||
// Call delegate to update DragDropManager's draggedClone reference
|
||||
payload.replaceClone(allDayElement);
|
||||
|
||||
// Append to container
|
||||
allDayContainer.appendChild(allDayElement);
|
||||
|
||||
ColumnDetectionUtils.updateColumnBoundsCache();
|
||||
|
||||
// Recalculate height after adding all-day event
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle drag move for all-day events - SPECIALIZED FOR ALL-DAY CONTAINER
|
||||
*/
|
||||
private handleColumnChange(dragColumnChangeEventPayload: IDragColumnChangeEventPayload): void {
|
||||
|
||||
let allDayContainer = this.getAllDayContainer();
|
||||
if (!allDayContainer) return;
|
||||
|
||||
let targetColumn = ColumnDetectionUtils.getColumnBounds(dragColumnChangeEventPayload.mousePosition);
|
||||
|
||||
if (targetColumn == null)
|
||||
return;
|
||||
|
||||
if (!dragColumnChangeEventPayload.draggedClone)
|
||||
return;
|
||||
|
||||
// Calculate event span from original grid positioning
|
||||
const computedStyle = window.getComputedStyle(dragColumnChangeEventPayload.draggedClone);
|
||||
const gridColumnStart = parseInt(computedStyle.gridColumnStart) || targetColumn.index;
|
||||
const gridColumnEnd = parseInt(computedStyle.gridColumnEnd) || targetColumn.index + 1;
|
||||
const span = gridColumnEnd - gridColumnStart;
|
||||
|
||||
// Update clone position maintaining the span
|
||||
const newStartColumn = targetColumn.index;
|
||||
const newEndColumn = newStartColumn + span;
|
||||
dragColumnChangeEventPayload.draggedClone.style.gridColumn = `${newStartColumn} / ${newEndColumn}`;
|
||||
|
||||
}
|
||||
private fadeOutAndRemove(element: HTMLElement): void {
|
||||
console.log('🗑️ AllDayManager: About to remove all-day event', {
|
||||
eventId: element.dataset.eventId,
|
||||
element: element.tagName
|
||||
});
|
||||
|
||||
// Mark element as removing so it's excluded from height calculations
|
||||
element.setAttribute('data-removing', 'true');
|
||||
|
||||
element.style.transition = 'opacity 0.3s ease-out';
|
||||
element.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
element.remove();
|
||||
console.log('✅ AllDayManager: All-day event removed from DOM');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle timed → all-day conversion on drop
|
||||
*/
|
||||
private async handleTimedToAllDayDrop(dragEndEvent: IDragEndEventPayload): Promise<void> {
|
||||
if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return;
|
||||
|
||||
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
|
||||
const eventId = EventId.from(clone.eventId);
|
||||
const columnIdentifier = dragEndEvent.finalPosition.column.identifier;
|
||||
|
||||
// Determine target date based on mode
|
||||
let targetDate: Date;
|
||||
let resourceId: string | undefined;
|
||||
|
||||
if (this.dataSource.isResource()) {
|
||||
// Resource mode: keep event's existing date, set resourceId
|
||||
targetDate = clone.start;
|
||||
resourceId = columnIdentifier;
|
||||
} else {
|
||||
// Date mode: parse date from column identifier
|
||||
targetDate = this.dateService.parseISO(columnIdentifier);
|
||||
}
|
||||
|
||||
console.log('🔄 AllDayManager: Converting timed event to all-day', { eventId, targetDate, resourceId });
|
||||
|
||||
// Create new dates preserving time
|
||||
const newStart = new Date(targetDate);
|
||||
newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0);
|
||||
|
||||
const newEnd = new Date(targetDate);
|
||||
newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0);
|
||||
|
||||
// Build update payload
|
||||
const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = {
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
allDay: true
|
||||
};
|
||||
|
||||
if (resourceId) {
|
||||
updatePayload.resourceId = resourceId;
|
||||
}
|
||||
|
||||
// Update event in repository
|
||||
await this.eventManager.updateEvent(eventId, updatePayload);
|
||||
|
||||
// Remove original timed event
|
||||
this.fadeOutAndRemove(dragEndEvent.originalElement);
|
||||
|
||||
// Add to current all-day events and recalculate layout
|
||||
const newEvent: ICalendarEvent = {
|
||||
id: eventId,
|
||||
title: clone.title,
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
type: clone.type as CalendarEventType,
|
||||
allDay: true,
|
||||
syncStatus: 'synced'
|
||||
};
|
||||
|
||||
const updatedEvents = [...this.currentAllDayEvents, newEvent];
|
||||
const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns);
|
||||
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
|
||||
|
||||
// Animate height
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle all-day → all-day drop (moving within header)
|
||||
*/
|
||||
private async handleDragEnd(dragEndEvent: IDragEndEventPayload): Promise<void> {
|
||||
if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column) return;
|
||||
|
||||
const clone = dragEndEvent.draggedClone as SwpAllDayEventElement;
|
||||
const eventId = EventId.from(clone.eventId);
|
||||
const columnIdentifier = dragEndEvent.finalPosition.column.identifier;
|
||||
|
||||
// Determine target date based on mode
|
||||
let targetDate: Date;
|
||||
let resourceId: string | undefined;
|
||||
|
||||
if (this.dataSource.isResource()) {
|
||||
// Resource mode: keep event's existing date, set resourceId
|
||||
targetDate = clone.start;
|
||||
resourceId = columnIdentifier;
|
||||
} else {
|
||||
// Date mode: parse date from column identifier
|
||||
targetDate = this.dateService.parseISO(columnIdentifier);
|
||||
}
|
||||
|
||||
// Calculate duration in days
|
||||
const durationDays = this.dateService.differenceInCalendarDays(clone.end, clone.start);
|
||||
|
||||
// Create new dates preserving time
|
||||
const newStart = new Date(targetDate);
|
||||
newStart.setHours(clone.start.getHours(), clone.start.getMinutes(), 0, 0);
|
||||
|
||||
const newEnd = new Date(targetDate);
|
||||
newEnd.setDate(newEnd.getDate() + durationDays);
|
||||
newEnd.setHours(clone.end.getHours(), clone.end.getMinutes(), 0, 0);
|
||||
|
||||
// Build update payload
|
||||
const updatePayload: { start: Date; end: Date; allDay: boolean; resourceId?: string } = {
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
allDay: true
|
||||
};
|
||||
|
||||
if (resourceId) {
|
||||
updatePayload.resourceId = resourceId;
|
||||
}
|
||||
|
||||
// Update event in repository
|
||||
await this.eventManager.updateEvent(eventId, updatePayload);
|
||||
|
||||
// Remove original and fade out
|
||||
this.fadeOutAndRemove(dragEndEvent.originalElement);
|
||||
|
||||
// Recalculate and re-render ALL events
|
||||
const updatedEvents = this.currentAllDayEvents.map(e =>
|
||||
e.id === eventId ? { ...e, start: newStart, end: newEnd } : e
|
||||
);
|
||||
const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentColumns);
|
||||
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
|
||||
|
||||
// Animate height - this also handles overflow classes!
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update chevron button visibility and state
|
||||
*/
|
||||
private updateChevronButton(show: boolean): void {
|
||||
const headerSpacer = this.getHeaderSpacer();
|
||||
if (!headerSpacer) return;
|
||||
|
||||
let chevron = headerSpacer.querySelector('.allday-chevron') as HTMLElement;
|
||||
|
||||
if (show && !chevron) {
|
||||
|
||||
chevron = document.createElement('button');
|
||||
chevron.className = 'allday-chevron collapsed';
|
||||
chevron.innerHTML = `
|
||||
<svg width="12" height="8" viewBox="0 0 12 8">
|
||||
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||
</svg>
|
||||
`;
|
||||
chevron.onclick = () => this.toggleExpanded();
|
||||
headerSpacer.appendChild(chevron);
|
||||
|
||||
} else if (!show && chevron) {
|
||||
|
||||
chevron.remove();
|
||||
|
||||
} else if (chevron) {
|
||||
|
||||
chevron.classList.toggle('collapsed', !this.isExpanded);
|
||||
chevron.classList.toggle('expanded', this.isExpanded);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between expanded and collapsed state
|
||||
*/
|
||||
private toggleExpanded(): void {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
|
||||
const elements = document.querySelectorAll('swp-allday-container swp-allday-event.max-event-overflow-hide, swp-allday-container swp-allday-event.max-event-overflow-show');
|
||||
|
||||
elements.forEach((element) => {
|
||||
if (this.isExpanded) {
|
||||
// ALTID vis når expanded=true
|
||||
element.classList.remove('max-event-overflow-hide');
|
||||
element.classList.add('max-event-overflow-show');
|
||||
} else {
|
||||
// ALTID skjul når expanded=false
|
||||
element.classList.remove('max-event-overflow-show');
|
||||
element.classList.add('max-event-overflow-hide');
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Count number of events in a specific column using IColumnBounds
|
||||
* Reads directly from DOM elements
|
||||
*/
|
||||
private countEventsInColumn(columnBounds: IColumnBounds): number {
|
||||
return this.countEventsInColumnFromDOM(columnBounds.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update overflow indicators for collapsed state
|
||||
*/
|
||||
private updateOverflowIndicators(): void {
|
||||
const container = this.getAllDayContainer();
|
||||
if (!container) return;
|
||||
|
||||
// Create overflow indicators for each column that needs them
|
||||
let columns = ColumnDetectionUtils.getColumns();
|
||||
|
||||
columns.forEach((columnBounds) => {
|
||||
let totalEventsInColumn = this.countEventsInColumn(columnBounds);
|
||||
let overflowCount = totalEventsInColumn - ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS
|
||||
|
||||
if (overflowCount > 0) {
|
||||
// Check if indicator already exists in this column
|
||||
let existingIndicator = container.querySelector(`.max-event-indicator[data-column="${columnBounds.index}"]`) as HTMLElement;
|
||||
|
||||
if (existingIndicator) {
|
||||
// Update existing indicator
|
||||
existingIndicator.innerHTML = `<span>+${overflowCount + 1} more</span>`;
|
||||
} else {
|
||||
// Create new overflow indicator element
|
||||
let overflowElement = document.createElement('swp-allday-event');
|
||||
overflowElement.className = 'max-event-indicator';
|
||||
overflowElement.setAttribute('data-column', columnBounds.index.toString());
|
||||
overflowElement.style.gridRow = ALL_DAY_CONSTANTS.MAX_COLLAPSED_ROWS.toString();
|
||||
overflowElement.style.gridColumn = columnBounds.index.toString();
|
||||
overflowElement.innerHTML = `<span>+${overflowCount + 1} more</span>`;
|
||||
overflowElement.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleExpanded();
|
||||
};
|
||||
|
||||
container.appendChild(overflowElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear overflow indicators and restore normal state
|
||||
*/
|
||||
private clearOverflowIndicators(): void {
|
||||
const container = this.getAllDayContainer();
|
||||
if (!container) return;
|
||||
|
||||
// Remove all overflow indicator elements
|
||||
container.querySelectorAll('.max-event-indicator').forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { CalendarView, IEventBus } from '../types/CalendarTypes';
|
||||
import { EventManager } from './EventManager';
|
||||
import { GridManager } from './GridManager';
|
||||
import { EventRenderingService } from '../renderers/EventRendererManager';
|
||||
import { ScrollManager } from './ScrollManager';
|
||||
|
||||
/**
|
||||
* CalendarManager - Main coordinator for all calendar managers
|
||||
*/
|
||||
export class CalendarManager {
|
||||
private eventBus: IEventBus;
|
||||
private eventManager: EventManager;
|
||||
private gridManager: GridManager;
|
||||
private eventRenderer: EventRenderingService;
|
||||
private scrollManager: ScrollManager;
|
||||
private config: Configuration;
|
||||
private currentView: CalendarView;
|
||||
private currentDate: Date = new Date();
|
||||
private isInitialized: boolean = false;
|
||||
|
||||
constructor(
|
||||
eventBus: IEventBus,
|
||||
eventManager: EventManager,
|
||||
gridManager: GridManager,
|
||||
eventRenderingService: EventRenderingService,
|
||||
scrollManager: ScrollManager,
|
||||
config: Configuration
|
||||
) {
|
||||
this.eventBus = eventBus;
|
||||
this.eventManager = eventManager;
|
||||
this.gridManager = gridManager;
|
||||
this.eventRenderer = eventRenderingService;
|
||||
this.scrollManager = scrollManager;
|
||||
this.config = config;
|
||||
this.currentView = this.config.currentView;
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize calendar system using simple direct calls
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Step 1: Load data
|
||||
await this.eventManager.loadData();
|
||||
|
||||
// Step 2: Render grid structure
|
||||
await this.gridManager.render();
|
||||
|
||||
this.scrollManager.initialize();
|
||||
|
||||
this.setView(this.currentView);
|
||||
this.setCurrentDate(this.currentDate);
|
||||
|
||||
this.isInitialized = true;
|
||||
|
||||
// Emit initialization complete event
|
||||
this.eventBus.emit(CoreEvents.INITIALIZED, {
|
||||
currentDate: this.currentDate,
|
||||
currentView: this.currentView
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skift calendar view (dag/uge/måned)
|
||||
*/
|
||||
public setView(view: CalendarView): void {
|
||||
if (this.currentView === view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousView = this.currentView;
|
||||
this.currentView = view;
|
||||
|
||||
|
||||
// Emit view change event
|
||||
this.eventBus.emit(CoreEvents.VIEW_CHANGED, {
|
||||
previousView,
|
||||
currentView: view,
|
||||
date: this.currentDate
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Sæt aktuel dato
|
||||
*/
|
||||
public setCurrentDate(date: Date): void {
|
||||
|
||||
const previousDate = this.currentDate;
|
||||
this.currentDate = new Date(date);
|
||||
|
||||
// Emit date change event
|
||||
this.eventBus.emit(CoreEvents.DATE_CHANGED, {
|
||||
previousDate,
|
||||
currentDate: this.currentDate,
|
||||
view: this.currentView
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Setup event listeners for at håndtere events fra andre managers
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
// Listen for workweek changes only
|
||||
this.eventBus.on(CoreEvents.WORKWEEK_CHANGED, (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
this.handleWorkweekChange();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Calculate the current period based on view and date
|
||||
*/
|
||||
private calculateCurrentPeriod(): { start: string; end: string } {
|
||||
const current = new Date(this.currentDate);
|
||||
|
||||
switch (this.currentView) {
|
||||
case 'day':
|
||||
const dayStart = new Date(current);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(current);
|
||||
dayEnd.setHours(23, 59, 59, 999);
|
||||
return {
|
||||
start: dayStart.toISOString(),
|
||||
end: dayEnd.toISOString()
|
||||
};
|
||||
|
||||
case 'week':
|
||||
// Find start of week (Monday)
|
||||
const weekStart = new Date(current);
|
||||
const dayOfWeek = weekStart.getDay();
|
||||
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Sunday = 0, so 6 days back to Monday
|
||||
weekStart.setDate(weekStart.getDate() - daysToMonday);
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
|
||||
// Find end of week (Sunday)
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
weekEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
return {
|
||||
start: weekStart.toISOString(),
|
||||
end: weekEnd.toISOString()
|
||||
};
|
||||
|
||||
case 'month':
|
||||
const monthStart = new Date(current.getFullYear(), current.getMonth(), 1);
|
||||
const monthEnd = new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
return {
|
||||
start: monthStart.toISOString(),
|
||||
end: monthEnd.toISOString()
|
||||
};
|
||||
|
||||
default:
|
||||
// Fallback to week view
|
||||
const fallbackStart = new Date(current);
|
||||
fallbackStart.setDate(fallbackStart.getDate() - 3);
|
||||
fallbackStart.setHours(0, 0, 0, 0);
|
||||
const fallbackEnd = new Date(current);
|
||||
fallbackEnd.setDate(fallbackEnd.getDate() + 3);
|
||||
fallbackEnd.setHours(23, 59, 59, 999);
|
||||
return {
|
||||
start: fallbackStart.toISOString(),
|
||||
end: fallbackEnd.toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle workweek configuration changes
|
||||
*/
|
||||
private handleWorkweekChange(): void {
|
||||
// Simply relay the event - workweek info is in the WORKWEEK_CHANGED event
|
||||
this.eventBus.emit('workweek:header-update', {
|
||||
currentDate: this.currentDate,
|
||||
currentView: this.currentView
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,220 +1,140 @@
|
|||
/**
|
||||
* EdgeScrollManager - Auto-scroll when dragging near edges
|
||||
* Uses time-based scrolling with 2-zone system for variable speed
|
||||
*/
|
||||
|
||||
import { IEventBus } from '../types/CalendarTypes';
|
||||
import { IDragMoveEventPayload, IDragStartEventPayload } from '../types/EventTypes';
|
||||
|
||||
export class EdgeScrollManager {
|
||||
private scrollableContent: HTMLElement | null = null;
|
||||
private timeGrid: HTMLElement | null = null;
|
||||
private draggedClone: HTMLElement | null = null;
|
||||
private scrollRAF: number | null = null;
|
||||
private mouseY = 0;
|
||||
private isDragging = false;
|
||||
private isScrolling = false; // Track if edge-scroll is active
|
||||
private lastTs = 0;
|
||||
private rect: DOMRect | null = null;
|
||||
private initialScrollTop = 0;
|
||||
private scrollListener: ((e: Event) => void) | null = null;
|
||||
|
||||
// Constants - fixed values as per requirements
|
||||
private readonly OUTER_ZONE = 100; // px from edge (slow zone)
|
||||
private readonly INNER_ZONE = 50; // px from edge (fast zone)
|
||||
private readonly SLOW_SPEED_PXS = 140; // px/sec in outer zone
|
||||
private readonly FAST_SPEED_PXS = 640; // px/sec in inner zone
|
||||
|
||||
constructor(private eventBus: IEventBus) {
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
// Wait for DOM to be ready
|
||||
setTimeout(() => {
|
||||
this.scrollableContent = document.querySelector('swp-scrollable-content');
|
||||
this.timeGrid = document.querySelector('swp-time-grid');
|
||||
|
||||
if (this.scrollableContent) {
|
||||
// Disable smooth scroll for instant auto-scroll
|
||||
this.scrollableContent.style.scrollBehavior = 'auto';
|
||||
|
||||
// Add scroll listener to detect actual scrolling
|
||||
this.scrollListener = this.handleScroll.bind(this);
|
||||
this.scrollableContent.addEventListener('scroll', this.scrollListener, { passive: true });
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Listen to mousemove directly from document to always get mouse coords
|
||||
document.body.addEventListener('mousemove', (e: MouseEvent) => {
|
||||
if (this.isDragging) {
|
||||
this.mouseY = e.clientY;
|
||||
}
|
||||
});
|
||||
|
||||
this.subscribeToEvents();
|
||||
}
|
||||
|
||||
private subscribeToEvents(): void {
|
||||
|
||||
// Listen to drag events from DragDropManager
|
||||
this.eventBus.on('drag:start', (event: Event) => {
|
||||
const payload = (event as CustomEvent).detail;
|
||||
this.draggedClone = payload.draggedClone;
|
||||
this.startDrag();
|
||||
});
|
||||
|
||||
this.eventBus.on('drag:end', () => this.stopDrag());
|
||||
this.eventBus.on('drag:cancelled', () => this.stopDrag());
|
||||
|
||||
// Stop scrolling when event converts to/from all-day
|
||||
this.eventBus.on('drag:mouseenter-header', () => {
|
||||
console.log('🔄 EdgeScrollManager: Event converting to all-day - stopping scroll');
|
||||
this.stopDrag();
|
||||
});
|
||||
|
||||
this.eventBus.on('drag:mouseenter-column', () => {
|
||||
this.startDrag();
|
||||
});
|
||||
}
|
||||
|
||||
private startDrag(): void {
|
||||
console.log('🎬 EdgeScrollManager: Starting drag');
|
||||
this.isDragging = true;
|
||||
this.isScrolling = false; // Reset scroll state
|
||||
this.lastTs = performance.now();
|
||||
|
||||
// Save initial scroll position
|
||||
if (this.scrollableContent) {
|
||||
this.initialScrollTop = this.scrollableContent.scrollTop;
|
||||
}
|
||||
|
||||
if (this.scrollRAF === null) {
|
||||
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
|
||||
}
|
||||
}
|
||||
|
||||
private stopDrag(): void {
|
||||
this.isDragging = false;
|
||||
|
||||
// Emit stopped event if we were scrolling
|
||||
if (this.isScrolling) {
|
||||
this.isScrolling = false;
|
||||
console.log('🛑 EdgeScrollManager: Edge-scroll stopped (drag ended)');
|
||||
this.eventBus.emit('edgescroll:stopped', {});
|
||||
}
|
||||
|
||||
if (this.scrollRAF !== null) {
|
||||
cancelAnimationFrame(this.scrollRAF);
|
||||
this.scrollRAF = null;
|
||||
}
|
||||
this.rect = null;
|
||||
this.lastTs = 0;
|
||||
this.initialScrollTop = 0;
|
||||
}
|
||||
|
||||
private handleScroll(): void {
|
||||
if (!this.isDragging || !this.scrollableContent) return;
|
||||
|
||||
const currentScrollTop = this.scrollableContent.scrollTop;
|
||||
const scrollDelta = Math.abs(currentScrollTop - this.initialScrollTop);
|
||||
|
||||
// Only emit started event if we've actually scrolled more than 1px
|
||||
if (scrollDelta > 1 && !this.isScrolling) {
|
||||
this.isScrolling = true;
|
||||
console.log('💾 EdgeScrollManager: Edge-scroll started (actual scroll detected)', {
|
||||
initialScrollTop: this.initialScrollTop,
|
||||
currentScrollTop,
|
||||
scrollDelta
|
||||
});
|
||||
this.eventBus.emit('edgescroll:started', {});
|
||||
}
|
||||
}
|
||||
|
||||
private scrollTick(ts: number): void {
|
||||
const dt = this.lastTs ? (ts - this.lastTs) / 1000 : 0;
|
||||
this.lastTs = ts;
|
||||
|
||||
if (!this.scrollableContent) {
|
||||
this.stopDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache rect for performance (only measure once per frame)
|
||||
if (!this.rect) {
|
||||
this.rect = this.scrollableContent.getBoundingClientRect();
|
||||
}
|
||||
|
||||
let vy = 0;
|
||||
if (this.isDragging) {
|
||||
const distTop = this.mouseY - this.rect.top;
|
||||
const distBot = this.rect.bottom - this.mouseY;
|
||||
|
||||
// Check top edge
|
||||
if (distTop < this.INNER_ZONE) {
|
||||
vy = -this.FAST_SPEED_PXS;
|
||||
} else if (distTop < this.OUTER_ZONE) {
|
||||
vy = -this.SLOW_SPEED_PXS;
|
||||
}
|
||||
// Check bottom edge
|
||||
else if (distBot < this.INNER_ZONE) {
|
||||
vy = this.FAST_SPEED_PXS;
|
||||
} else if (distBot < this.OUTER_ZONE) {
|
||||
vy = this.SLOW_SPEED_PXS;
|
||||
}
|
||||
}
|
||||
|
||||
if (vy !== 0 && this.isDragging && this.timeGrid && this.draggedClone) {
|
||||
// Check if we can scroll in the requested direction
|
||||
const currentScrollTop = this.scrollableContent.scrollTop;
|
||||
const scrollableHeight = this.scrollableContent.clientHeight;
|
||||
const timeGridHeight = this.timeGrid.clientHeight;
|
||||
|
||||
// Get dragged element position and height
|
||||
const cloneRect = this.draggedClone.getBoundingClientRect();
|
||||
const cloneBottom = cloneRect.bottom;
|
||||
const timeGridRect = this.timeGrid.getBoundingClientRect();
|
||||
const timeGridBottom = timeGridRect.bottom;
|
||||
|
||||
// Check boundaries
|
||||
const atTop = currentScrollTop <= 0 && vy < 0;
|
||||
const atBottom = (cloneBottom >= timeGridBottom) && vy > 0;
|
||||
|
||||
|
||||
if (atTop || atBottom) {
|
||||
// At boundary - stop scrolling
|
||||
if (this.isScrolling) {
|
||||
this.isScrolling = false;
|
||||
this.initialScrollTop = this.scrollableContent.scrollTop;
|
||||
console.log('🛑 EdgeScrollManager: Edge-scroll stopped (reached boundary)');
|
||||
this.eventBus.emit('edgescroll:stopped', {});
|
||||
}
|
||||
|
||||
// Continue RAF loop to detect when mouse moves away from boundary
|
||||
if (this.isDragging) {
|
||||
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
|
||||
}
|
||||
} else {
|
||||
// Not at boundary - apply scroll
|
||||
this.scrollableContent.scrollTop += vy * dt;
|
||||
this.rect = null; // Invalidate cache for next frame
|
||||
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
|
||||
}
|
||||
} else {
|
||||
// Mouse moved away from edge - stop scrolling
|
||||
if (this.isScrolling) {
|
||||
this.isScrolling = false;
|
||||
this.initialScrollTop = this.scrollableContent.scrollTop; // Reset for next scroll
|
||||
console.log('🛑 EdgeScrollManager: Edge-scroll stopped (mouse left edge)');
|
||||
this.eventBus.emit('edgescroll:stopped', {});
|
||||
}
|
||||
|
||||
// Continue RAF loop even if not scrolling, to detect edge entry
|
||||
if (this.isDragging) {
|
||||
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
|
||||
} else {
|
||||
this.stopDrag();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* EdgeScrollManager - Auto-scroll when dragging near viewport edges
|
||||
*
|
||||
* 2-zone system:
|
||||
* - Inner zone (0-50px): Fast scroll (640 px/sec)
|
||||
* - Outer zone (50-100px): Slow scroll (140 px/sec)
|
||||
*/
|
||||
|
||||
import { IEventBus } from '../types/CalendarTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
|
||||
export class EdgeScrollManager {
|
||||
private scrollableContent: HTMLElement | null = null;
|
||||
private timeGrid: HTMLElement | null = null;
|
||||
private draggedElement: HTMLElement | null = null;
|
||||
private scrollRAF: number | null = null;
|
||||
private mouseY = 0;
|
||||
private isDragging = false;
|
||||
private isScrolling = false;
|
||||
private lastTs = 0;
|
||||
private rect: DOMRect | null = null;
|
||||
private initialScrollTop = 0;
|
||||
|
||||
private readonly OUTER_ZONE = 100;
|
||||
private readonly INNER_ZONE = 50;
|
||||
private readonly SLOW_SPEED = 140;
|
||||
private readonly FAST_SPEED = 640;
|
||||
|
||||
constructor(private eventBus: IEventBus) {
|
||||
this.subscribeToEvents();
|
||||
document.addEventListener('pointermove', this.trackMouse);
|
||||
}
|
||||
|
||||
init(scrollableContent: HTMLElement): void {
|
||||
this.scrollableContent = scrollableContent;
|
||||
this.timeGrid = scrollableContent.querySelector('swp-time-grid');
|
||||
this.scrollableContent.style.scrollBehavior = 'auto';
|
||||
}
|
||||
|
||||
private trackMouse = (e: PointerEvent): void => {
|
||||
if (this.isDragging) {
|
||||
this.mouseY = e.clientY;
|
||||
}
|
||||
};
|
||||
|
||||
private subscribeToEvents(): void {
|
||||
this.eventBus.on(CoreEvents.EVENT_DRAG_START, (event: Event) => {
|
||||
const payload = (event as CustomEvent).detail;
|
||||
this.draggedElement = payload.element;
|
||||
this.startDrag();
|
||||
});
|
||||
|
||||
this.eventBus.on(CoreEvents.EVENT_DRAG_END, () => this.stopDrag());
|
||||
this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => this.stopDrag());
|
||||
}
|
||||
|
||||
private startDrag(): void {
|
||||
this.isDragging = true;
|
||||
this.isScrolling = false;
|
||||
this.lastTs = 0;
|
||||
this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0;
|
||||
|
||||
if (this.scrollRAF === null) {
|
||||
this.scrollRAF = requestAnimationFrame(this.scrollTick);
|
||||
}
|
||||
}
|
||||
|
||||
private stopDrag(): void {
|
||||
this.isDragging = false;
|
||||
this.setScrollingState(false);
|
||||
|
||||
if (this.scrollRAF !== null) {
|
||||
cancelAnimationFrame(this.scrollRAF);
|
||||
this.scrollRAF = null;
|
||||
}
|
||||
|
||||
this.rect = null;
|
||||
this.lastTs = 0;
|
||||
this.initialScrollTop = 0;
|
||||
}
|
||||
|
||||
private calculateVelocity(): number {
|
||||
if (!this.rect) return 0;
|
||||
|
||||
const distTop = this.mouseY - this.rect.top;
|
||||
const distBot = this.rect.bottom - this.mouseY;
|
||||
|
||||
if (distTop < this.INNER_ZONE) return -this.FAST_SPEED;
|
||||
if (distTop < this.OUTER_ZONE) return -this.SLOW_SPEED;
|
||||
if (distBot < this.INNER_ZONE) return this.FAST_SPEED;
|
||||
if (distBot < this.OUTER_ZONE) return this.SLOW_SPEED;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private isAtBoundary(velocity: number): boolean {
|
||||
if (!this.scrollableContent || !this.timeGrid || !this.draggedElement) return false;
|
||||
|
||||
const atTop = this.scrollableContent.scrollTop <= 0 && velocity < 0;
|
||||
const atBottom = velocity > 0 &&
|
||||
this.draggedElement.getBoundingClientRect().bottom >=
|
||||
this.timeGrid.getBoundingClientRect().bottom;
|
||||
|
||||
return atTop || atBottom;
|
||||
}
|
||||
|
||||
private setScrollingState(scrolling: boolean): void {
|
||||
if (this.isScrolling === scrolling) return;
|
||||
|
||||
this.isScrolling = scrolling;
|
||||
if (scrolling) {
|
||||
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STARTED, {});
|
||||
} else {
|
||||
this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0;
|
||||
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STOPPED, {});
|
||||
}
|
||||
}
|
||||
|
||||
private scrollTick = (ts: number): void => {
|
||||
if (!this.isDragging || !this.scrollableContent) return;
|
||||
|
||||
const dt = this.lastTs ? (ts - this.lastTs) / 1000 : 0;
|
||||
this.lastTs = ts;
|
||||
this.rect ??= this.scrollableContent.getBoundingClientRect();
|
||||
|
||||
const velocity = this.calculateVelocity();
|
||||
|
||||
if (velocity !== 0 && !this.isAtBoundary(velocity)) {
|
||||
const scrollDelta = velocity * dt;
|
||||
this.scrollableContent.scrollTop += scrollDelta;
|
||||
this.rect = null;
|
||||
this.eventBus.emit(CoreEvents.EDGE_SCROLL_TICK, { scrollDelta });
|
||||
this.setScrollingState(true);
|
||||
} else {
|
||||
this.setScrollingState(false);
|
||||
}
|
||||
|
||||
this.scrollRAF = requestAnimationFrame(this.scrollTick);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,229 +0,0 @@
|
|||
/**
|
||||
* EventFilterManager - Handles fuzzy search filtering of calendar events
|
||||
* Uses Fuse.js for fuzzy matching (Apache 2.0 License)
|
||||
*/
|
||||
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
|
||||
// Import Fuse.js from npm
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
interface FuseResult {
|
||||
item: ICalendarEvent;
|
||||
refIndex: number;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export class EventFilterManager {
|
||||
private searchInput: HTMLInputElement | null = null;
|
||||
private allEvents: ICalendarEvent[] = [];
|
||||
private matchingEventIds: Set<string> = new Set();
|
||||
private isFilterActive: boolean = false;
|
||||
private frameRequest: number | null = null;
|
||||
private fuse: Fuse<ICalendarEvent> | null = null;
|
||||
|
||||
constructor() {
|
||||
// Wait for DOM to be ready before initializing
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.init();
|
||||
});
|
||||
} else {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
// Find search input
|
||||
this.searchInput = document.querySelector('swp-search-container input[type="search"]');
|
||||
|
||||
if (!this.searchInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
this.setupSearchListeners();
|
||||
this.subscribeToEvents();
|
||||
|
||||
// Initialization complete
|
||||
}
|
||||
|
||||
private setupSearchListeners(): void {
|
||||
if (!this.searchInput) return;
|
||||
|
||||
// Listen for input changes
|
||||
this.searchInput.addEventListener('input', (e) => {
|
||||
const query = (e.target as HTMLInputElement).value;
|
||||
this.handleSearchInput(query);
|
||||
});
|
||||
|
||||
// Listen for escape key
|
||||
this.searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.clearFilter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private subscribeToEvents(): void {
|
||||
// Listen for events data updates
|
||||
eventBus.on(CoreEvents.EVENTS_RENDERED, (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.events) {
|
||||
this.updateEventsList(detail.events);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateEventsList(events: ICalendarEvent[]): void {
|
||||
this.allEvents = events;
|
||||
|
||||
// Initialize Fuse with the new events list
|
||||
this.fuse = new Fuse(this.allEvents, {
|
||||
keys: ['title', 'description'],
|
||||
threshold: 0.3,
|
||||
includeScore: true,
|
||||
minMatchCharLength: 2, // Minimum 2 characters for a match
|
||||
shouldSort: true,
|
||||
ignoreLocation: true // Search anywhere in the string
|
||||
});
|
||||
|
||||
|
||||
// Re-apply filter if active
|
||||
if (this.isFilterActive && this.searchInput) {
|
||||
this.applyFilter(this.searchInput.value);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSearchInput(query: string): void {
|
||||
// Cancel any pending filter
|
||||
if (this.frameRequest) {
|
||||
cancelAnimationFrame(this.frameRequest);
|
||||
}
|
||||
|
||||
// Debounce with requestAnimationFrame
|
||||
this.frameRequest = requestAnimationFrame(() => {
|
||||
if (query.length === 0) {
|
||||
// Only clear when input is completely empty
|
||||
this.clearFilter();
|
||||
} else {
|
||||
// Let Fuse.js handle minimum character length via minMatchCharLength
|
||||
this.applyFilter(query);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private applyFilter(query: string): void {
|
||||
if (!this.fuse) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform fuzzy search
|
||||
const results = this.fuse.search(query);
|
||||
|
||||
// Extract matching event IDs
|
||||
this.matchingEventIds.clear();
|
||||
results.forEach((result: FuseResult) => {
|
||||
if (result.item && result.item.id) {
|
||||
this.matchingEventIds.add(result.item.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Update filter state
|
||||
this.isFilterActive = true;
|
||||
|
||||
// Update visual state
|
||||
this.updateVisualState();
|
||||
|
||||
// Emit filter changed event
|
||||
eventBus.emit(CoreEvents.FILTER_CHANGED, {
|
||||
active: true,
|
||||
query: query,
|
||||
matchingIds: Array.from(this.matchingEventIds)
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private clearFilter(): void {
|
||||
this.isFilterActive = false;
|
||||
this.matchingEventIds.clear();
|
||||
|
||||
// Clear search input
|
||||
if (this.searchInput) {
|
||||
this.searchInput.value = '';
|
||||
}
|
||||
|
||||
// Update visual state
|
||||
this.updateVisualState();
|
||||
|
||||
// Emit filter cleared event
|
||||
eventBus.emit(CoreEvents.FILTER_CHANGED, {
|
||||
active: false,
|
||||
query: '',
|
||||
matchingIds: []
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private updateVisualState(): void {
|
||||
// Update search container styling
|
||||
const searchContainer = document.querySelector('swp-search-container');
|
||||
if (searchContainer) {
|
||||
if (this.isFilterActive) {
|
||||
searchContainer.classList.add('filter-active');
|
||||
} else {
|
||||
searchContainer.classList.remove('filter-active');
|
||||
}
|
||||
}
|
||||
|
||||
// Update all events layers
|
||||
const eventsLayers = document.querySelectorAll('swp-events-layer');
|
||||
eventsLayers.forEach(layer => {
|
||||
if (this.isFilterActive) {
|
||||
layer.setAttribute('data-filter-active', 'true');
|
||||
|
||||
// Mark matching events
|
||||
const events = layer.querySelectorAll('swp-event');
|
||||
events.forEach(event => {
|
||||
const eventId = event.getAttribute('data-event-id');
|
||||
if (eventId && this.matchingEventIds.has(eventId)) {
|
||||
event.setAttribute('data-matches', 'true');
|
||||
} else {
|
||||
event.removeAttribute('data-matches');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
layer.removeAttribute('data-filter-active');
|
||||
|
||||
// Remove all match attributes
|
||||
const events = layer.querySelectorAll('swp-event');
|
||||
events.forEach(event => {
|
||||
event.removeAttribute('data-matches');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event matches the current filter
|
||||
*/
|
||||
public eventMatchesFilter(eventId: string): boolean {
|
||||
if (!this.isFilterActive) {
|
||||
return true; // No filter active, all events match
|
||||
}
|
||||
return this.matchingEventIds.has(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current filter state
|
||||
*/
|
||||
public getFilterState(): { active: boolean; matchingIds: string[] } {
|
||||
return {
|
||||
active: this.isFilterActive,
|
||||
matchingIds: Array.from(this.matchingEventIds)
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
/**
|
||||
* EventLayoutCoordinator - Coordinates event layout calculations
|
||||
*
|
||||
* Separates layout logic from rendering concerns.
|
||||
* Calculates stack levels, groups events, and determines rendering strategy.
|
||||
*/
|
||||
|
||||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { EventStackManager, IEventGroup, IStackLink } from './EventStackManager';
|
||||
import { PositionUtils } from '../utils/PositionUtils';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
|
||||
export interface IGridGroupLayout {
|
||||
events: ICalendarEvent[];
|
||||
stackLevel: number;
|
||||
position: { top: number };
|
||||
columns: ICalendarEvent[][]; // Events grouped by column (events in same array share a column)
|
||||
}
|
||||
|
||||
export interface IStackedEventLayout {
|
||||
event: ICalendarEvent;
|
||||
stackLink: IStackLink;
|
||||
position: { top: number; height: number };
|
||||
}
|
||||
|
||||
export interface IColumnLayout {
|
||||
gridGroups: IGridGroupLayout[];
|
||||
stackedEvents: IStackedEventLayout[];
|
||||
}
|
||||
|
||||
export class EventLayoutCoordinator {
|
||||
private stackManager: EventStackManager;
|
||||
private config: Configuration;
|
||||
private positionUtils: PositionUtils;
|
||||
|
||||
constructor(stackManager: EventStackManager, config: Configuration, positionUtils: PositionUtils) {
|
||||
this.stackManager = stackManager;
|
||||
this.config = config;
|
||||
this.positionUtils = positionUtils;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate complete layout for a column of events (recursive approach)
|
||||
*/
|
||||
public calculateColumnLayout(columnEvents: ICalendarEvent[]): IColumnLayout {
|
||||
if (columnEvents.length === 0) {
|
||||
return { gridGroups: [], stackedEvents: [] };
|
||||
}
|
||||
|
||||
const gridGroupLayouts: IGridGroupLayout[] = [];
|
||||
const stackedEventLayouts: IStackedEventLayout[] = [];
|
||||
const renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }> = [];
|
||||
let remaining = [...columnEvents].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
|
||||
// Process events recursively
|
||||
while (remaining.length > 0) {
|
||||
// Take first event
|
||||
const firstEvent = remaining[0];
|
||||
|
||||
// Find events that could be in GRID with first event
|
||||
// Use expanding search to find chains (A→B→C where each conflicts with next)
|
||||
const gridSettings = this.config.gridSettings;
|
||||
const thresholdMinutes = gridSettings.gridStartThresholdMinutes;
|
||||
|
||||
// Use refactored method for expanding grid candidates
|
||||
const gridCandidates = this.expandGridCandidates(firstEvent, remaining, thresholdMinutes);
|
||||
|
||||
// Decide: should this group be GRID or STACK?
|
||||
const group: IEventGroup = {
|
||||
events: gridCandidates,
|
||||
containerType: 'NONE',
|
||||
startTime: firstEvent.start
|
||||
};
|
||||
const containerType = this.stackManager.decideContainerType(group);
|
||||
|
||||
if (containerType === 'GRID' && gridCandidates.length > 1) {
|
||||
// Render as GRID
|
||||
const gridStackLevel = this.calculateGridGroupStackLevelFromRendered(
|
||||
gridCandidates,
|
||||
renderedEventsWithLevels
|
||||
);
|
||||
|
||||
// Ensure we get the earliest event (explicit sort for robustness)
|
||||
const earliestEvent = [...gridCandidates].sort((a, b) => a.start.getTime() - b.start.getTime())[0];
|
||||
const position = this.positionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end);
|
||||
const columns = this.allocateColumns(gridCandidates);
|
||||
|
||||
gridGroupLayouts.push({
|
||||
events: gridCandidates,
|
||||
stackLevel: gridStackLevel,
|
||||
position: { top: position.top + 1 },
|
||||
columns
|
||||
});
|
||||
|
||||
// Mark all events in grid with their stack level
|
||||
gridCandidates.forEach(e => renderedEventsWithLevels.push({ event: e, level: gridStackLevel }));
|
||||
|
||||
// Remove all events in this grid from remaining
|
||||
remaining = remaining.filter(e => !gridCandidates.includes(e));
|
||||
} else {
|
||||
// Render first event as STACKED
|
||||
const stackLevel = this.calculateStackLevelFromRendered(
|
||||
firstEvent,
|
||||
renderedEventsWithLevels
|
||||
);
|
||||
|
||||
const position = this.positionUtils.calculateEventPosition(firstEvent.start, firstEvent.end);
|
||||
stackedEventLayouts.push({
|
||||
event: firstEvent,
|
||||
stackLink: { stackLevel },
|
||||
position: { top: position.top + 1, height: position.height - 3 }
|
||||
});
|
||||
|
||||
// Mark this event with its stack level
|
||||
renderedEventsWithLevels.push({ event: firstEvent, level: stackLevel });
|
||||
|
||||
// Remove only first event from remaining
|
||||
remaining = remaining.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gridGroups: gridGroupLayouts,
|
||||
stackedEvents: stackedEventLayouts
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate stack level for a grid group based on already rendered events
|
||||
*/
|
||||
private calculateGridGroupStackLevelFromRendered(
|
||||
gridEvents: ICalendarEvent[],
|
||||
renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }>
|
||||
): number {
|
||||
// Find highest stack level of any rendered event that overlaps with this grid
|
||||
let maxOverlappingLevel = -1;
|
||||
|
||||
for (const gridEvent of gridEvents) {
|
||||
for (const rendered of renderedEventsWithLevels) {
|
||||
if (this.stackManager.doEventsOverlap(gridEvent, rendered.event)) {
|
||||
maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxOverlappingLevel + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate stack level for a single stacked event based on already rendered events
|
||||
*/
|
||||
private calculateStackLevelFromRendered(
|
||||
event: ICalendarEvent,
|
||||
renderedEventsWithLevels: Array<{ event: ICalendarEvent; level: number }>
|
||||
): number {
|
||||
// Find highest stack level of any rendered event that overlaps with this event
|
||||
let maxOverlappingLevel = -1;
|
||||
|
||||
for (const rendered of renderedEventsWithLevels) {
|
||||
if (this.stackManager.doEventsOverlap(event, rendered.event)) {
|
||||
maxOverlappingLevel = Math.max(maxOverlappingLevel, rendered.level);
|
||||
}
|
||||
}
|
||||
|
||||
return maxOverlappingLevel + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if two events have a conflict based on threshold
|
||||
*
|
||||
* @param event1 - First event
|
||||
* @param event2 - Second event
|
||||
* @param thresholdMinutes - Threshold in minutes
|
||||
* @returns true if events conflict
|
||||
*/
|
||||
private detectConflict(event1: ICalendarEvent, event2: ICalendarEvent, thresholdMinutes: number): boolean {
|
||||
// Check 1: Start-to-start conflict (starts within threshold)
|
||||
const startToStartDiff = Math.abs(event1.start.getTime() - event2.start.getTime()) / (1000 * 60);
|
||||
if (startToStartDiff <= thresholdMinutes && this.stackManager.doEventsOverlap(event1, event2)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check 2: End-to-start conflict (event1 starts within threshold before event2 ends)
|
||||
const endToStartMinutes = (event2.end.getTime() - event1.start.getTime()) / (1000 * 60);
|
||||
if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check 3: Reverse end-to-start (event2 starts within threshold before event1 ends)
|
||||
const reverseEndToStart = (event1.end.getTime() - event2.start.getTime()) / (1000 * 60);
|
||||
if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand grid candidates to find all events connected by conflict chains
|
||||
*
|
||||
* Uses expanding search to find chains (A→B→C where each conflicts with next)
|
||||
*
|
||||
* @param firstEvent - The first event to start with
|
||||
* @param remaining - Remaining events to check
|
||||
* @param thresholdMinutes - Threshold in minutes
|
||||
* @returns Array of all events in the conflict chain
|
||||
*/
|
||||
private expandGridCandidates(
|
||||
firstEvent: ICalendarEvent,
|
||||
remaining: ICalendarEvent[],
|
||||
thresholdMinutes: number
|
||||
): ICalendarEvent[] {
|
||||
const gridCandidates = [firstEvent];
|
||||
let candidatesChanged = true;
|
||||
|
||||
// Keep expanding until no new candidates can be added
|
||||
while (candidatesChanged) {
|
||||
candidatesChanged = false;
|
||||
|
||||
for (let i = 1; i < remaining.length; i++) {
|
||||
const candidate = remaining[i];
|
||||
|
||||
// Skip if already in candidates
|
||||
if (gridCandidates.includes(candidate)) continue;
|
||||
|
||||
// Check if candidate conflicts with ANY event in gridCandidates
|
||||
for (const existingCandidate of gridCandidates) {
|
||||
if (this.detectConflict(candidate, existingCandidate, thresholdMinutes)) {
|
||||
gridCandidates.push(candidate);
|
||||
candidatesChanged = true;
|
||||
break; // Found conflict, move to next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gridCandidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate events to columns within a grid group
|
||||
*
|
||||
* Events that don't overlap can share the same column.
|
||||
* Uses a greedy algorithm to minimize the number of columns.
|
||||
*
|
||||
* @param events - Events in the grid group (should already be sorted by start time)
|
||||
* @returns Array of columns, where each column is an array of events
|
||||
*/
|
||||
private allocateColumns(events: ICalendarEvent[]): ICalendarEvent[][] {
|
||||
if (events.length === 0) return [];
|
||||
if (events.length === 1) return [[events[0]]];
|
||||
|
||||
const columns: ICalendarEvent[][] = [];
|
||||
|
||||
// For each event, try to place it in an existing column where it doesn't overlap
|
||||
for (const event of events) {
|
||||
let placed = false;
|
||||
|
||||
// Try to find a column where this event doesn't overlap with any existing event
|
||||
for (const column of columns) {
|
||||
const hasOverlap = column.some(colEvent =>
|
||||
this.stackManager.doEventsOverlap(event, colEvent)
|
||||
);
|
||||
|
||||
if (!hasOverlap) {
|
||||
column.push(event);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no suitable column found, create a new one
|
||||
if (!placed) {
|
||||
columns.push([event]);
|
||||
}
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import { IEventBus, ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { EventService } from '../storage/events/EventService';
|
||||
import { IEntityService } from '../storage/IEntityService';
|
||||
|
||||
/**
|
||||
* EventManager - Event lifecycle and CRUD operations
|
||||
* Delegates all data operations to EventService
|
||||
* EventService provides CRUD operations via BaseEntityService (save, delete, getAll)
|
||||
*/
|
||||
export class EventManager {
|
||||
|
||||
private dateService: DateService;
|
||||
private config: Configuration;
|
||||
private eventService: EventService;
|
||||
|
||||
constructor(
|
||||
private eventBus: IEventBus,
|
||||
dateService: DateService,
|
||||
config: Configuration,
|
||||
eventService: IEntityService<ICalendarEvent>
|
||||
) {
|
||||
this.dateService = dateService;
|
||||
this.config = config;
|
||||
this.eventService = eventService as EventService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load event data from service
|
||||
* Ensures data is loaded (called during initialization)
|
||||
*/
|
||||
public async loadData(): Promise<void> {
|
||||
try {
|
||||
// Just ensure service is ready - getAll() will return data
|
||||
await this.eventService.getAll();
|
||||
} catch (error) {
|
||||
console.error('Failed to load event data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all events from service
|
||||
*/
|
||||
public async getEvents(copy: boolean = false): Promise<ICalendarEvent[]> {
|
||||
const events = await this.eventService.getAll();
|
||||
return copy ? [...events] : events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event by ID from service
|
||||
*/
|
||||
public async getEventById(id: string): Promise<ICalendarEvent | undefined> {
|
||||
const event = await this.eventService.get(id);
|
||||
return event || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event by ID and return event info for navigation
|
||||
* @param id Event ID to find
|
||||
* @returns Event with navigation info or null if not found
|
||||
*/
|
||||
public async getEventForNavigation(id: string): Promise<{ event: ICalendarEvent; eventDate: Date } | null> {
|
||||
const event = await this.getEventById(id);
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate event dates
|
||||
const validation = this.dateService.validateDate(event.start);
|
||||
if (!validation.valid) {
|
||||
console.warn(`EventManager: Invalid event start date for event ${id}:`, validation.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate date range
|
||||
if (!this.dateService.isValidRange(event.start, event.end)) {
|
||||
console.warn(`EventManager: Invalid date range for event ${id}: start must be before end`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
eventDate: event.start
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to specific event by ID
|
||||
* Emits navigation events for other managers to handle
|
||||
* @param eventId Event ID to navigate to
|
||||
* @returns true if event found and navigation initiated, false otherwise
|
||||
*/
|
||||
public async navigateToEvent(eventId: string): Promise<boolean> {
|
||||
const eventInfo = await this.getEventForNavigation(eventId);
|
||||
if (!eventInfo) {
|
||||
console.warn(`EventManager: Event with ID ${eventId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { event, eventDate } = eventInfo;
|
||||
|
||||
// Emit navigation request event
|
||||
this.eventBus.emit(CoreEvents.NAVIGATE_TO_EVENT, {
|
||||
eventId,
|
||||
event,
|
||||
eventDate,
|
||||
eventStartTime: event.start
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events that overlap with a given time period
|
||||
*/
|
||||
public async getEventsForPeriod(startDate: Date, endDate: Date): Promise<ICalendarEvent[]> {
|
||||
const events = await this.eventService.getAll();
|
||||
// Event overlaps period if it starts before period ends AND ends after period starts
|
||||
return events.filter(event => {
|
||||
return event.start <= endDate && event.end >= startDate;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new event and add it to the calendar
|
||||
* Generates ID and saves via EventService
|
||||
*/
|
||||
public async addEvent(event: Omit<ICalendarEvent, 'id'>): Promise<ICalendarEvent> {
|
||||
// Generate unique ID
|
||||
const id = `event-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const newEvent: ICalendarEvent = {
|
||||
...event,
|
||||
id,
|
||||
syncStatus: 'synced' // No queue yet, mark as synced
|
||||
};
|
||||
|
||||
await this.eventService.save(newEvent);
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_CREATED, {
|
||||
event: newEvent
|
||||
});
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing event
|
||||
* Merges updates with existing event and saves
|
||||
*/
|
||||
public async updateEvent(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent | null> {
|
||||
try {
|
||||
const existingEvent = await this.eventService.get(id);
|
||||
if (!existingEvent) {
|
||||
throw new Error(`Event with ID ${id} not found`);
|
||||
}
|
||||
|
||||
const updatedEvent: ICalendarEvent = {
|
||||
...existingEvent,
|
||||
...updates,
|
||||
id, // Ensure ID doesn't change
|
||||
syncStatus: 'synced' // No queue yet, mark as synced
|
||||
};
|
||||
|
||||
await this.eventService.save(updatedEvent);
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
|
||||
event: updatedEvent
|
||||
});
|
||||
|
||||
return updatedEvent;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update event ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
* Calls EventService.delete()
|
||||
*/
|
||||
public async deleteEvent(id: string): Promise<boolean> {
|
||||
try {
|
||||
await this.eventService.delete(id);
|
||||
|
||||
this.eventBus.emit(CoreEvents.EVENT_DELETED, {
|
||||
eventId: id
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete event ${id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/managers/EventPersistenceManager.ts
Normal file
102
src/managers/EventPersistenceManager.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* EventPersistenceManager - Persists event changes to IndexedDB
|
||||
*
|
||||
* Listens to drag/resize events and updates IndexedDB via EventService.
|
||||
* This bridges the gap between UI interactions and data persistence.
|
||||
*/
|
||||
|
||||
import { ICalendarEvent, IEventBus, IEventUpdatedPayload } from '../types/CalendarTypes';
|
||||
import { EventService } from '../storage/events/EventService';
|
||||
import { DateService } from '../core/DateService';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { IDragEndPayload } from '../types/DragTypes';
|
||||
import { IResizeEndPayload } from '../types/ResizeTypes';
|
||||
|
||||
export class EventPersistenceManager {
|
||||
constructor(
|
||||
private eventService: EventService,
|
||||
private eventBus: IEventBus,
|
||||
private dateService: DateService
|
||||
) {
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
private setupListeners(): void {
|
||||
this.eventBus.on(CoreEvents.EVENT_DRAG_END, this.handleDragEnd);
|
||||
this.eventBus.on(CoreEvents.EVENT_RESIZE_END, this.handleResizeEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag end - update event position in IndexedDB
|
||||
*/
|
||||
private handleDragEnd = async (e: Event): Promise<void> => {
|
||||
const payload = (e as CustomEvent<IDragEndPayload>).detail;
|
||||
const { swpEvent } = payload;
|
||||
|
||||
// Get existing event to merge with
|
||||
const event = await this.eventService.get(swpEvent.eventId);
|
||||
if (!event) {
|
||||
console.warn(`EventPersistenceManager: Event ${swpEvent.eventId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse resourceId from columnKey if present
|
||||
const { resource } = this.dateService.parseColumnKey(swpEvent.columnKey);
|
||||
|
||||
// Update and save - start/end already calculated in SwpEvent
|
||||
// Set allDay based on drop target:
|
||||
// - header: allDay = true
|
||||
// - grid: allDay = false (converts allDay event to timed)
|
||||
const updatedEvent: ICalendarEvent = {
|
||||
...event,
|
||||
start: swpEvent.start,
|
||||
end: swpEvent.end,
|
||||
resourceId: resource ?? event.resourceId,
|
||||
allDay: payload.target === 'header',
|
||||
syncStatus: 'pending'
|
||||
};
|
||||
|
||||
await this.eventService.save(updatedEvent);
|
||||
|
||||
// Emit EVENT_UPDATED for EventRenderer to re-render affected columns
|
||||
const updatePayload: IEventUpdatedPayload = {
|
||||
eventId: updatedEvent.id,
|
||||
sourceColumnKey: payload.sourceColumnKey,
|
||||
targetColumnKey: swpEvent.columnKey
|
||||
};
|
||||
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle resize end - update event duration in IndexedDB
|
||||
*/
|
||||
private handleResizeEnd = async (e: Event): Promise<void> => {
|
||||
const payload = (e as CustomEvent<IResizeEndPayload>).detail;
|
||||
const { swpEvent } = payload;
|
||||
|
||||
// Get existing event to merge with
|
||||
const event = await this.eventService.get(swpEvent.eventId);
|
||||
if (!event) {
|
||||
console.warn(`EventPersistenceManager: Event ${swpEvent.eventId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update and save - end already calculated in SwpEvent
|
||||
const updatedEvent: ICalendarEvent = {
|
||||
...event,
|
||||
end: swpEvent.end,
|
||||
syncStatus: 'pending'
|
||||
};
|
||||
|
||||
await this.eventService.save(updatedEvent);
|
||||
|
||||
// Emit EVENT_UPDATED for EventRenderer to re-render the column
|
||||
// Resize stays in same column, so source and target are the same
|
||||
const updatePayload: IEventUpdatedPayload = {
|
||||
eventId: updatedEvent.id,
|
||||
sourceColumnKey: swpEvent.columnKey,
|
||||
targetColumnKey: swpEvent.columnKey
|
||||
};
|
||||
this.eventBus.emit(CoreEvents.EVENT_UPDATED, updatePayload);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
/**
|
||||
* EventStackManager - Manages visual stacking of overlapping calendar events
|
||||
*
|
||||
* This class handles the creation and maintenance of "stack chains" - doubly-linked
|
||||
* lists of overlapping events stored directly in DOM elements via data attributes.
|
||||
*
|
||||
* Implements 3-phase algorithm for grid + nested stacking:
|
||||
* Phase 1: Group events by start time proximity (configurable threshold)
|
||||
* Phase 2: Decide container type (GRID vs STACKING)
|
||||
* Phase 3: Handle late arrivals (nested stacking - NOT IMPLEMENTED)
|
||||
*
|
||||
* @see STACKING_CONCEPT.md for detailed documentation
|
||||
* @see stacking-visualization.html for visual examples
|
||||
*/
|
||||
|
||||
import { ICalendarEvent } from '../types/CalendarTypes';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
|
||||
export interface IStackLink {
|
||||
prev?: string; // Event ID of previous event in stack
|
||||
next?: string; // Event ID of next event in stack
|
||||
stackLevel: number; // Position in stack (0 = base, 1 = first offset, etc.)
|
||||
}
|
||||
|
||||
export interface IEventGroup {
|
||||
events: ICalendarEvent[];
|
||||
containerType: 'NONE' | 'GRID' | 'STACKING';
|
||||
startTime: Date;
|
||||
}
|
||||
|
||||
export class EventStackManager {
|
||||
private static readonly STACK_OFFSET_PX = 15;
|
||||
private config: Configuration;
|
||||
|
||||
constructor(config: Configuration) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PHASE 1: Start Time Grouping
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Group events by time conflicts (both start-to-start and end-to-start within threshold)
|
||||
*
|
||||
* Events are grouped if:
|
||||
* 1. They start within ±threshold minutes of each other (start-to-start)
|
||||
* 2. One event starts within threshold minutes before another ends (end-to-start conflict)
|
||||
*/
|
||||
public groupEventsByStartTime(events: ICalendarEvent[]): IEventGroup[] {
|
||||
if (events.length === 0) return [];
|
||||
|
||||
// Get threshold from config
|
||||
const gridSettings = this.config.gridSettings;
|
||||
const thresholdMinutes = gridSettings.gridStartThresholdMinutes;
|
||||
|
||||
// Sort events by start time
|
||||
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
|
||||
const groups: IEventGroup[] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
// Find existing group that this event conflicts with
|
||||
const existingGroup = groups.find(group => {
|
||||
// Check if event conflicts with ANY event in the group
|
||||
return group.events.some(groupEvent => {
|
||||
// Start-to-start conflict: events start within threshold
|
||||
const startToStartMinutes = Math.abs(event.start.getTime() - groupEvent.start.getTime()) / (1000 * 60);
|
||||
if (startToStartMinutes <= thresholdMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// End-to-start conflict: event starts within threshold before groupEvent ends
|
||||
const endToStartMinutes = (groupEvent.end.getTime() - event.start.getTime()) / (1000 * 60);
|
||||
if (endToStartMinutes > 0 && endToStartMinutes <= thresholdMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check reverse: groupEvent starts within threshold before event ends
|
||||
const reverseEndToStart = (event.end.getTime() - groupEvent.start.getTime()) / (1000 * 60);
|
||||
if (reverseEndToStart > 0 && reverseEndToStart <= thresholdMinutes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
if (existingGroup) {
|
||||
existingGroup.events.push(event);
|
||||
} else {
|
||||
groups.push({
|
||||
events: [event],
|
||||
containerType: 'NONE',
|
||||
startTime: event.start
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
|
||||
// ============================================
|
||||
// PHASE 2: Container Type Decision
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Decide container type for a group of events
|
||||
*
|
||||
* Rule: Events starting simultaneously (within threshold) should ALWAYS use GRID,
|
||||
* even if they overlap each other. This provides better visual indication that
|
||||
* events start at the same time.
|
||||
*/
|
||||
public decideContainerType(group: IEventGroup): 'NONE' | 'GRID' | 'STACKING' {
|
||||
if (group.events.length === 1) {
|
||||
return 'NONE';
|
||||
}
|
||||
|
||||
// If events are grouped together (start within threshold), they should share columns (GRID)
|
||||
// This is true EVEN if they overlap, because the visual priority is to show
|
||||
// that they start simultaneously.
|
||||
return 'GRID';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if two events overlap in time
|
||||
*/
|
||||
public doEventsOverlap(event1: ICalendarEvent, event2: ICalendarEvent): boolean {
|
||||
return event1.start < event2.end && event1.end > event2.start;
|
||||
}
|
||||
|
||||
|
||||
// ============================================
|
||||
// Stack Level Calculation
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create optimized stack links (events share levels when possible)
|
||||
*/
|
||||
public createOptimizedStackLinks(events: ICalendarEvent[]): Map<string, IStackLink> {
|
||||
const stackLinks = new Map<string, IStackLink>();
|
||||
|
||||
if (events.length === 0) return stackLinks;
|
||||
|
||||
// Sort by start time
|
||||
const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
|
||||
// Step 1: Assign stack levels
|
||||
for (const event of sorted) {
|
||||
// Find all events this event overlaps with
|
||||
const overlapping = sorted.filter(other =>
|
||||
other !== event && this.doEventsOverlap(event, other)
|
||||
);
|
||||
|
||||
// Find the MINIMUM required level (must be above all overlapping events)
|
||||
let minRequiredLevel = 0;
|
||||
for (const other of overlapping) {
|
||||
const otherLink = stackLinks.get(other.id);
|
||||
if (otherLink) {
|
||||
// Must be at least one level above the overlapping event
|
||||
minRequiredLevel = Math.max(minRequiredLevel, otherLink.stackLevel + 1);
|
||||
}
|
||||
}
|
||||
|
||||
stackLinks.set(event.id, { stackLevel: minRequiredLevel });
|
||||
}
|
||||
|
||||
// Step 2: Build prev/next chains for overlapping events at adjacent stack levels
|
||||
for (const event of sorted) {
|
||||
const currentLink = stackLinks.get(event.id)!;
|
||||
|
||||
// Find overlapping events that are directly below (stackLevel - 1)
|
||||
const overlapping = sorted.filter(other =>
|
||||
other !== event && this.doEventsOverlap(event, other)
|
||||
);
|
||||
|
||||
const directlyBelow = overlapping.filter(other => {
|
||||
const otherLink = stackLinks.get(other.id);
|
||||
return otherLink && otherLink.stackLevel === currentLink.stackLevel - 1;
|
||||
});
|
||||
|
||||
if (directlyBelow.length > 0) {
|
||||
// Use the first one in sorted order as prev
|
||||
currentLink.prev = directlyBelow[0].id;
|
||||
}
|
||||
|
||||
// Find overlapping events that are directly above (stackLevel + 1)
|
||||
const directlyAbove = overlapping.filter(other => {
|
||||
const otherLink = stackLinks.get(other.id);
|
||||
return otherLink && otherLink.stackLevel === currentLink.stackLevel + 1;
|
||||
});
|
||||
|
||||
if (directlyAbove.length > 0) {
|
||||
// Use the first one in sorted order as next
|
||||
currentLink.next = directlyAbove[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
return stackLinks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate marginLeft based on stack level
|
||||
*/
|
||||
public calculateMarginLeft(stackLevel: number): number {
|
||||
return stackLevel * EventStackManager.STACK_OFFSET_PX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate zIndex based on stack level
|
||||
*/
|
||||
public calculateZIndex(stackLevel: number): number {
|
||||
return 100 + stackLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize stack link to JSON string
|
||||
*/
|
||||
public serializeStackLink(stackLink: IStackLink): string {
|
||||
return JSON.stringify(stackLink);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize JSON string to stack link
|
||||
*/
|
||||
public deserializeStackLink(json: string): IStackLink | null {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stack link to DOM element
|
||||
*/
|
||||
public applyStackLinkToElement(element: HTMLElement, stackLink: IStackLink): void {
|
||||
element.dataset.stackLink = this.serializeStackLink(stackLink);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stack link from DOM element
|
||||
*/
|
||||
public getStackLinkFromElement(element: HTMLElement): IStackLink | null {
|
||||
const data = element.dataset.stackLink;
|
||||
if (!data) return null;
|
||||
return this.deserializeStackLink(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply visual styling to element based on stack level
|
||||
*/
|
||||
public applyVisualStyling(element: HTMLElement, stackLevel: number): void {
|
||||
element.style.marginLeft = `${this.calculateMarginLeft(stackLevel)}px`;
|
||||
element.style.zIndex = `${this.calculateZIndex(stackLevel)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stack link from element
|
||||
*/
|
||||
public clearStackLinkFromElement(element: HTMLElement): void {
|
||||
delete element.dataset.stackLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear visual styling from element
|
||||
*/
|
||||
public clearVisualStyling(element: HTMLElement): void {
|
||||
element.style.marginLeft = '';
|
||||
element.style.zIndex = '';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
/**
|
||||
* GridManager - Simplified grid manager using centralized GridRenderer
|
||||
* Delegates DOM rendering to GridRenderer, focuses on coordination
|
||||
*
|
||||
* Note: Events are now provided by IColumnDataSource (each column has its own events)
|
||||
*/
|
||||
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { CalendarView } from '../types/CalendarTypes';
|
||||
import { GridRenderer } from '../renderers/GridRenderer';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { IColumnDataSource } from '../types/ColumnDataSource';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
|
||||
/**
|
||||
* Simplified GridManager focused on coordination, delegates rendering to GridRenderer
|
||||
*/
|
||||
export class GridManager {
|
||||
private container: HTMLElement | null = null;
|
||||
private currentDate: Date = new Date();
|
||||
private currentView: CalendarView = 'week';
|
||||
private gridRenderer: GridRenderer;
|
||||
private dateService: DateService;
|
||||
private config: Configuration;
|
||||
private dataSource: IColumnDataSource;
|
||||
|
||||
constructor(
|
||||
gridRenderer: GridRenderer,
|
||||
dateService: DateService,
|
||||
config: Configuration,
|
||||
dataSource: IColumnDataSource
|
||||
) {
|
||||
this.gridRenderer = gridRenderer;
|
||||
this.dateService = dateService;
|
||||
this.config = config;
|
||||
this.dataSource = dataSource;
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.findElements();
|
||||
this.subscribeToEvents();
|
||||
}
|
||||
|
||||
private findElements(): void {
|
||||
this.container = document.querySelector('swp-calendar-container');
|
||||
}
|
||||
|
||||
private subscribeToEvents(): void {
|
||||
// Listen for view changes
|
||||
eventBus.on(CoreEvents.VIEW_CHANGED, (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
this.currentView = detail.currentView;
|
||||
this.dataSource.setCurrentView(this.currentView);
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Listen for navigation events from NavigationManager
|
||||
// NavigationManager has already created new grid with animation
|
||||
// GridManager only needs to update state, NOT re-render
|
||||
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
this.currentDate = detail.newDate;
|
||||
this.dataSource.setCurrentDate(this.currentDate);
|
||||
// Do NOT call render() - NavigationManager already created new grid
|
||||
});
|
||||
|
||||
// Listen for config changes that affect rendering
|
||||
eventBus.on(CoreEvents.REFRESH_REQUESTED, (e: Event) => {
|
||||
this.render();
|
||||
});
|
||||
|
||||
eventBus.on(CoreEvents.WORKWEEK_CHANGED, () => {
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Main render method - delegates to GridRenderer
|
||||
* Note: CSS variables are automatically updated by ConfigManager when config changes
|
||||
* Note: Events are included in columns from IColumnDataSource
|
||||
*/
|
||||
public async render(): Promise<void> {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get columns from datasource - single source of truth (includes events per column)
|
||||
const columns = await this.dataSource.getColumns();
|
||||
|
||||
// Set grid columns CSS variable based on actual column count
|
||||
document.documentElement.style.setProperty('--grid-columns', columns.length.toString());
|
||||
|
||||
// Delegate to GridRenderer with columns (events are inside each column)
|
||||
this.gridRenderer.renderGrid(
|
||||
this.container,
|
||||
this.currentDate,
|
||||
this.currentView,
|
||||
columns
|
||||
);
|
||||
|
||||
// Emit grid rendered event
|
||||
eventBus.emit(CoreEvents.GRID_RENDERED, {
|
||||
container: this.container,
|
||||
currentDate: this.currentDate,
|
||||
columns: columns
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
import { eventBus } from '../core/EventBus';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { IHeaderRenderer, IHeaderRenderContext } from '../renderers/DateHeaderRenderer';
|
||||
import { IDragMouseEnterHeaderEventPayload, IDragMouseLeaveHeaderEventPayload, IHeaderReadyEventPayload } from '../types/EventTypes';
|
||||
import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
|
||||
import { IColumnDataSource } from '../types/ColumnDataSource';
|
||||
|
||||
/**
|
||||
* HeaderManager - Handles all header-related event logic
|
||||
* Separates event handling from rendering concerns
|
||||
* Uses dependency injection for renderer strategy
|
||||
*/
|
||||
export class HeaderManager {
|
||||
private headerRenderer: IHeaderRenderer;
|
||||
private config: Configuration;
|
||||
private dataSource: IColumnDataSource;
|
||||
|
||||
constructor(headerRenderer: IHeaderRenderer, config: Configuration, dataSource: IColumnDataSource) {
|
||||
this.headerRenderer = headerRenderer;
|
||||
this.config = config;
|
||||
this.dataSource = dataSource;
|
||||
|
||||
// Bind handler methods for event listeners
|
||||
this.handleDragMouseEnterHeader = this.handleDragMouseEnterHeader.bind(this);
|
||||
this.handleDragMouseLeaveHeader = this.handleDragMouseLeaveHeader.bind(this);
|
||||
|
||||
// Listen for navigation events to update header
|
||||
this.setupNavigationListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup header drag event listeners - Listen to DragDropManager events
|
||||
*/
|
||||
public setupHeaderDragListeners(): void {
|
||||
console.log('🎯 HeaderManager: Setting up drag event listeners');
|
||||
|
||||
// Subscribe to drag events from DragDropManager
|
||||
eventBus.on('drag:mouseenter-header', this.handleDragMouseEnterHeader);
|
||||
eventBus.on('drag:mouseleave-header', this.handleDragMouseLeaveHeader);
|
||||
|
||||
console.log('✅ HeaderManager: Drag event listeners attached');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag mouse enter header event
|
||||
*/
|
||||
private handleDragMouseEnterHeader(event: Event): void {
|
||||
const { targetColumn, mousePosition, originalElement, draggedClone: cloneElement } =
|
||||
(event as CustomEvent<IDragMouseEnterHeaderEventPayload>).detail;
|
||||
|
||||
console.log('🎯 HeaderManager: Received drag:mouseenter-header', {
|
||||
targetColumn: targetColumn.identifier,
|
||||
originalElement: !!originalElement,
|
||||
cloneElement: !!cloneElement
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag mouse leave header event
|
||||
*/
|
||||
private handleDragMouseLeaveHeader(event: Event): void {
|
||||
const { targetColumn, mousePosition, originalElement, draggedClone: cloneElement } =
|
||||
(event as CustomEvent<IDragMouseLeaveHeaderEventPayload>).detail;
|
||||
|
||||
console.log('🚪 HeaderManager: Received drag:mouseleave-header', {
|
||||
targetColumn: targetColumn?.identifier,
|
||||
originalElement: !!originalElement,
|
||||
cloneElement: !!cloneElement
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup navigation event listener
|
||||
*/
|
||||
private setupNavigationListener(): void {
|
||||
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (event) => {
|
||||
const { currentDate } = (event as CustomEvent).detail;
|
||||
this.updateHeader(currentDate);
|
||||
});
|
||||
|
||||
// Also listen for date changes (including initial setup)
|
||||
eventBus.on(CoreEvents.DATE_CHANGED, (event) => {
|
||||
const { currentDate } = (event as CustomEvent).detail;
|
||||
this.updateHeader(currentDate);
|
||||
});
|
||||
|
||||
// Listen for workweek header updates after grid rebuild
|
||||
//currentDate: this.currentDate,
|
||||
//currentView: this.currentView,
|
||||
//workweek: this.config.currentWorkWeek
|
||||
eventBus.on('workweek:header-update', (event) => {
|
||||
const { currentDate } = (event as CustomEvent).detail;
|
||||
this.updateHeader(currentDate);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Update header content for navigation
|
||||
*/
|
||||
private async updateHeader(currentDate: Date): Promise<void> {
|
||||
console.log('🎯 HeaderManager.updateHeader called', {
|
||||
currentDate,
|
||||
rendererType: this.headerRenderer.constructor.name
|
||||
});
|
||||
|
||||
const calendarHeader = document.querySelector('swp-calendar-header') as HTMLElement;
|
||||
if (!calendarHeader) {
|
||||
console.warn('❌ HeaderManager: No calendar header found!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing content
|
||||
calendarHeader.innerHTML = '';
|
||||
|
||||
// Update DataSource with current date and get columns
|
||||
this.dataSource.setCurrentDate(currentDate);
|
||||
const columns = await this.dataSource.getColumns();
|
||||
|
||||
// Render new header content using injected renderer
|
||||
const context: IHeaderRenderContext = {
|
||||
columns: columns,
|
||||
config: this.config
|
||||
};
|
||||
|
||||
this.headerRenderer.render(calendarHeader, context);
|
||||
|
||||
// Setup event listeners on the new content
|
||||
this.setupHeaderDragListeners();
|
||||
|
||||
// Notify other managers that header is ready with period data
|
||||
const payload: IHeaderReadyEventPayload = {
|
||||
headerElements: ColumnDetectionUtils.getHeaderColumns(),
|
||||
};
|
||||
eventBus.emit('header:ready', payload);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
import { IEventBus, CalendarView } from '../types/CalendarTypes';
|
||||
import { EventRenderingService } from '../renderers/EventRendererManager';
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { WeekInfoRenderer } from '../renderers/WeekInfoRenderer';
|
||||
import { GridRenderer } from '../renderers/GridRenderer';
|
||||
import { INavButtonClickedEventPayload } from '../types/EventTypes';
|
||||
import { IColumnDataSource } from '../types/ColumnDataSource';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
|
||||
export class NavigationManager {
|
||||
private eventBus: IEventBus;
|
||||
private weekInfoRenderer: WeekInfoRenderer;
|
||||
private gridRenderer: GridRenderer;
|
||||
private dateService: DateService;
|
||||
private config: Configuration;
|
||||
private dataSource: IColumnDataSource;
|
||||
private currentWeek: Date;
|
||||
private targetWeek: Date;
|
||||
private animationQueue: number = 0;
|
||||
|
||||
constructor(
|
||||
eventBus: IEventBus,
|
||||
eventRenderer: EventRenderingService,
|
||||
gridRenderer: GridRenderer,
|
||||
dateService: DateService,
|
||||
weekInfoRenderer: WeekInfoRenderer,
|
||||
config: Configuration,
|
||||
dataSource: IColumnDataSource
|
||||
) {
|
||||
this.eventBus = eventBus;
|
||||
this.dateService = dateService;
|
||||
this.weekInfoRenderer = weekInfoRenderer;
|
||||
this.gridRenderer = gridRenderer;
|
||||
this.config = config;
|
||||
this.currentWeek = this.getISOWeekStart(new Date());
|
||||
this.targetWeek = new Date(this.currentWeek);
|
||||
this.dataSource = dataSource;
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start of the ISO week (Monday) for a given date
|
||||
* @param date - Any date in the week
|
||||
* @returns The Monday of the ISO week
|
||||
*/
|
||||
private getISOWeekStart(date: Date): Date {
|
||||
const weekBounds = this.dateService.getWeekBounds(date);
|
||||
return this.dateService.startOfDay(weekBounds.start);
|
||||
}
|
||||
|
||||
|
||||
private setupEventListeners(): void {
|
||||
|
||||
// Listen for filter changes and apply to pre-rendered grids
|
||||
this.eventBus.on(CoreEvents.FILTER_CHANGED, (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
this.weekInfoRenderer.applyFilterToPreRenderedGrids(detail);
|
||||
});
|
||||
|
||||
// Listen for navigation button clicks from NavigationButtons
|
||||
this.eventBus.on(CoreEvents.NAV_BUTTON_CLICKED, (event: Event) => {
|
||||
const { direction, newDate } = (event as CustomEvent<INavButtonClickedEventPayload>).detail;
|
||||
|
||||
// Navigate to the new date with animation
|
||||
this.navigateToDate(newDate, direction);
|
||||
});
|
||||
|
||||
// Listen for external navigation requests
|
||||
this.eventBus.on(CoreEvents.DATE_CHANGED, (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const dateFromEvent = customEvent.detail.currentDate;
|
||||
|
||||
// Validate date before processing
|
||||
if (!dateFromEvent) {
|
||||
console.warn('NavigationManager: No date provided in DATE_CHANGED event');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDate = new Date(dateFromEvent);
|
||||
|
||||
// Use DateService validation
|
||||
const validation = this.dateService.validateDate(targetDate);
|
||||
if (!validation.valid) {
|
||||
console.warn('NavigationManager: Invalid date received:', validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.navigateToDate(targetDate);
|
||||
});
|
||||
|
||||
// Listen for event navigation requests
|
||||
this.eventBus.on(CoreEvents.NAVIGATE_TO_EVENT, (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { eventDate, eventStartTime } = customEvent.detail;
|
||||
|
||||
if (!eventDate || !eventStartTime) {
|
||||
console.warn('NavigationManager: Invalid event navigation data');
|
||||
return;
|
||||
}
|
||||
|
||||
this.navigateToEventDate(eventDate, eventStartTime);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to specific event date and emit scroll event after navigation
|
||||
*/
|
||||
private navigateToEventDate(eventDate: Date, eventStartTime: string): void {
|
||||
const weekStart = this.getISOWeekStart(eventDate);
|
||||
this.targetWeek = new Date(weekStart);
|
||||
|
||||
const currentTime = this.currentWeek.getTime();
|
||||
const targetTime = weekStart.getTime();
|
||||
|
||||
// Store event start time for scrolling after navigation
|
||||
const scrollAfterNavigation = () => {
|
||||
// Emit scroll request after navigation is complete
|
||||
this.eventBus.emit('scroll:to-event-time', {
|
||||
eventStartTime
|
||||
});
|
||||
};
|
||||
|
||||
if (currentTime < targetTime) {
|
||||
this.animationQueue++;
|
||||
this.animateTransition('next', weekStart);
|
||||
// Listen for navigation completion to trigger scroll
|
||||
this.eventBus.once(CoreEvents.NAVIGATION_COMPLETED, scrollAfterNavigation);
|
||||
} else if (currentTime > targetTime) {
|
||||
this.animationQueue++;
|
||||
this.animateTransition('prev', weekStart);
|
||||
// Listen for navigation completion to trigger scroll
|
||||
this.eventBus.once(CoreEvents.NAVIGATION_COMPLETED, scrollAfterNavigation);
|
||||
} else {
|
||||
// Already on correct week, just scroll
|
||||
scrollAfterNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private navigateToDate(date: Date, direction?: 'next' | 'previous' | 'today'): void {
|
||||
const weekStart = this.getISOWeekStart(date);
|
||||
this.targetWeek = new Date(weekStart);
|
||||
|
||||
const currentTime = this.currentWeek.getTime();
|
||||
const targetTime = weekStart.getTime();
|
||||
|
||||
// Use provided direction or calculate based on time comparison
|
||||
let animationDirection: 'next' | 'prev';
|
||||
|
||||
if (direction === 'next') {
|
||||
animationDirection = 'next';
|
||||
} else if (direction === 'previous') {
|
||||
animationDirection = 'prev';
|
||||
} else if (direction === 'today') {
|
||||
// For "today", determine direction based on current position
|
||||
animationDirection = currentTime < targetTime ? 'next' : 'prev';
|
||||
} else {
|
||||
// Fallback: calculate direction
|
||||
animationDirection = currentTime < targetTime ? 'next' : 'prev';
|
||||
}
|
||||
|
||||
if (currentTime !== targetTime) {
|
||||
this.animationQueue++;
|
||||
this.animateTransition(animationDirection, weekStart);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation transition using pre-rendered containers when available
|
||||
*/
|
||||
private async animateTransition(direction: 'prev' | 'next', targetWeek: Date): Promise<void> {
|
||||
|
||||
const container = document.querySelector('swp-calendar-container') as HTMLElement;
|
||||
const currentGrid = document.querySelector('swp-calendar-container swp-grid-container:not([data-prerendered])') as HTMLElement;
|
||||
|
||||
if (!container || !currentGrid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset all-day height BEFORE creating new grid to ensure base height
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--all-day-row-height', '0px');
|
||||
|
||||
let newGrid: HTMLElement;
|
||||
|
||||
console.group('🔧 NavigationManager.refactored');
|
||||
console.log('Calling GridRenderer instead of NavigationRenderer');
|
||||
console.log('Target week:', targetWeek);
|
||||
|
||||
// Update DataSource with target week and get columns
|
||||
this.dataSource.setCurrentDate(targetWeek);
|
||||
const columns = await this.dataSource.getColumns();
|
||||
|
||||
// Always create a fresh container for consistent behavior
|
||||
newGrid = this.gridRenderer.createNavigationGrid(container, columns, targetWeek);
|
||||
|
||||
console.groupEnd();
|
||||
|
||||
|
||||
// Clear any existing transforms before animation
|
||||
newGrid.style.transform = '';
|
||||
currentGrid.style.transform = '';
|
||||
|
||||
// Animate transition using Web Animations API
|
||||
const slideOutAnimation = currentGrid.animate([
|
||||
{ transform: 'translateX(0)', opacity: '1' },
|
||||
{ transform: direction === 'next' ? 'translateX(-100%)' : 'translateX(100%)', opacity: '0.5' }
|
||||
], {
|
||||
duration: 400,
|
||||
easing: 'ease-in-out',
|
||||
fill: 'forwards'
|
||||
});
|
||||
|
||||
const slideInAnimation = newGrid.animate([
|
||||
{ transform: direction === 'next' ? 'translateX(100%)' : 'translateX(-100%)' },
|
||||
{ transform: 'translateX(0)' }
|
||||
], {
|
||||
duration: 400,
|
||||
easing: 'ease-in-out',
|
||||
fill: 'forwards'
|
||||
});
|
||||
|
||||
// Handle animation completion
|
||||
slideInAnimation.addEventListener('finish', () => {
|
||||
|
||||
// Cleanup: Remove all old grids except the new one
|
||||
const allGrids = container.querySelectorAll('swp-grid-container');
|
||||
for (let i = 0; i < allGrids.length - 1; i++) {
|
||||
allGrids[i].remove();
|
||||
}
|
||||
|
||||
// Reset positioning
|
||||
newGrid.style.position = 'relative';
|
||||
newGrid.removeAttribute('data-prerendered');
|
||||
|
||||
// Update state
|
||||
this.currentWeek = new Date(targetWeek);
|
||||
this.animationQueue--;
|
||||
|
||||
// If this was the last queued animation, ensure we're in sync
|
||||
if (this.animationQueue === 0) {
|
||||
this.currentWeek = new Date(this.targetWeek);
|
||||
}
|
||||
|
||||
// Emit navigation completed event
|
||||
this.eventBus.emit(CoreEvents.NAVIGATION_COMPLETED, {
|
||||
direction,
|
||||
newDate: this.currentWeek
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
import { eventBus } from '../core/EventBus';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { IResizeEndEventPayload } from '../types/EventTypes';
|
||||
import { PositionUtils } from '../utils/PositionUtils';
|
||||
|
||||
type SwpEventEl = HTMLElement & { updateHeight?: (h: number) => void };
|
||||
|
||||
export class ResizeHandleManager {
|
||||
private isResizing = false;
|
||||
private targetEl: SwpEventEl | null = null;
|
||||
|
||||
private startY = 0;
|
||||
private startDurationMin = 0;
|
||||
|
||||
private snapMin: number;
|
||||
private minDurationMin: number;
|
||||
private animationId: number | null = null;
|
||||
private currentHeight = 0;
|
||||
private targetHeight = 0;
|
||||
|
||||
private pointerCaptured = false;
|
||||
private prevZ?: string;
|
||||
|
||||
// Constants for better maintainability
|
||||
private readonly ANIMATION_SPEED = 0.35;
|
||||
private readonly Z_INDEX_RESIZING = '1000';
|
||||
private readonly EVENT_REFRESH_THRESHOLD = 0.5;
|
||||
|
||||
constructor(
|
||||
private config: Configuration,
|
||||
private positionUtils: PositionUtils
|
||||
) {
|
||||
const grid = this.config.gridSettings;
|
||||
this.snapMin = grid.snapInterval;
|
||||
this.minDurationMin = this.snapMin;
|
||||
}
|
||||
|
||||
public initialize(): void {
|
||||
this.attachGlobalListeners();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.removeEventListeners();
|
||||
}
|
||||
|
||||
private removeEventListeners(): void {
|
||||
const calendarContainer = document.querySelector('swp-calendar-container');
|
||||
if (calendarContainer) {
|
||||
calendarContainer.removeEventListener('mouseover', this.onMouseOver, true);
|
||||
}
|
||||
|
||||
document.removeEventListener('pointerdown', this.onPointerDown, true);
|
||||
document.removeEventListener('pointermove', this.onPointerMove, true);
|
||||
document.removeEventListener('pointerup', this.onPointerUp, true);
|
||||
}
|
||||
|
||||
private createResizeHandle(): HTMLElement {
|
||||
const handle = document.createElement('swp-resize-handle');
|
||||
handle.setAttribute('aria-label', 'Resize event');
|
||||
handle.setAttribute('role', 'separator');
|
||||
return handle;
|
||||
}
|
||||
|
||||
private attachGlobalListeners(): void {
|
||||
const calendarContainer = document.querySelector('swp-calendar-container');
|
||||
|
||||
if (calendarContainer) {
|
||||
calendarContainer.addEventListener('mouseover', this.onMouseOver, true);
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', this.onPointerDown, true);
|
||||
document.addEventListener('pointermove', this.onPointerMove, true);
|
||||
document.addEventListener('pointerup', this.onPointerUp, true);
|
||||
}
|
||||
|
||||
private onMouseOver = (e: Event): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
const eventElement = target.closest<SwpEventEl>('swp-event');
|
||||
|
||||
if (eventElement && !this.isResizing) {
|
||||
// Check if handle already exists
|
||||
if (!eventElement.querySelector(':scope > swp-resize-handle')) {
|
||||
const handle = this.createResizeHandle();
|
||||
eventElement.appendChild(handle);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onPointerDown = (e: PointerEvent): void => {
|
||||
const handle = (e.target as HTMLElement).closest('swp-resize-handle');
|
||||
if (!handle) return;
|
||||
|
||||
const element = handle.parentElement as SwpEventEl;
|
||||
this.startResizing(element, e);
|
||||
};
|
||||
|
||||
private startResizing(element: SwpEventEl, event: PointerEvent): void {
|
||||
this.targetEl = element;
|
||||
this.isResizing = true;
|
||||
this.startY = event.clientY;
|
||||
|
||||
const startHeight = element.offsetHeight;
|
||||
this.startDurationMin = Math.max(
|
||||
this.minDurationMin,
|
||||
Math.round(this.positionUtils.pixelsToMinutes(startHeight))
|
||||
);
|
||||
|
||||
this.setZIndexForResizing(element);
|
||||
this.capturePointer(event);
|
||||
document.documentElement.classList.add('swp--resizing');
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
private setZIndexForResizing(element: SwpEventEl): void {
|
||||
const container = element.closest<HTMLElement>('swp-event-group') ?? element;
|
||||
this.prevZ = container.style.zIndex;
|
||||
container.style.zIndex = this.Z_INDEX_RESIZING;
|
||||
}
|
||||
|
||||
private capturePointer(event: PointerEvent): void {
|
||||
try {
|
||||
(event.target as Element).setPointerCapture?.(event.pointerId);
|
||||
this.pointerCaptured = true;
|
||||
} catch (error) {
|
||||
console.warn('Pointer capture failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerMove = (e: PointerEvent): void => {
|
||||
if (!this.isResizing || !this.targetEl) return;
|
||||
|
||||
this.updateResizeHeight(e.clientY);
|
||||
};
|
||||
|
||||
private updateResizeHeight(currentY: number): void {
|
||||
const deltaY = currentY - this.startY;
|
||||
|
||||
const startHeight = this.positionUtils.minutesToPixels(this.startDurationMin);
|
||||
const rawHeight = startHeight + deltaY;
|
||||
const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin);
|
||||
|
||||
this.targetHeight = Math.max(minHeight, rawHeight);
|
||||
|
||||
if (this.animationId == null) {
|
||||
this.currentHeight = this.targetEl?.offsetHeight!!;
|
||||
this.animate();
|
||||
}
|
||||
}
|
||||
|
||||
private animate = (): void => {
|
||||
if (!this.isResizing || !this.targetEl) {
|
||||
this.animationId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = this.targetHeight - this.currentHeight;
|
||||
|
||||
if (Math.abs(diff) > this.EVENT_REFRESH_THRESHOLD) {
|
||||
this.currentHeight += diff * this.ANIMATION_SPEED;
|
||||
this.targetEl.updateHeight?.(this.currentHeight);
|
||||
this.animationId = requestAnimationFrame(this.animate);
|
||||
} else {
|
||||
this.finalizeAnimation();
|
||||
}
|
||||
};
|
||||
|
||||
private finalizeAnimation(): void {
|
||||
if (!this.targetEl) return;
|
||||
|
||||
this.currentHeight = this.targetHeight;
|
||||
this.targetEl.updateHeight?.(this.currentHeight);
|
||||
this.animationId = null;
|
||||
}
|
||||
|
||||
private onPointerUp = (e: PointerEvent): void => {
|
||||
if (!this.isResizing || !this.targetEl) return;
|
||||
|
||||
this.cleanupAnimation();
|
||||
this.snapToGrid();
|
||||
this.emitResizeEndEvent();
|
||||
this.cleanupResizing(e);
|
||||
};
|
||||
|
||||
private cleanupAnimation(): void {
|
||||
if (this.animationId != null) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private snapToGrid(): void {
|
||||
if (!this.targetEl) return;
|
||||
|
||||
const currentHeight = this.targetEl.offsetHeight;
|
||||
const snapDistancePx = this.positionUtils.minutesToPixels(this.snapMin);
|
||||
const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx;
|
||||
const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin);
|
||||
const finalHeight = Math.max(minHeight, snappedHeight) - 3; // Small gap to grid lines
|
||||
|
||||
this.targetEl.updateHeight?.(finalHeight);
|
||||
}
|
||||
|
||||
private emitResizeEndEvent(): void {
|
||||
if (!this.targetEl) return;
|
||||
|
||||
const eventId = this.targetEl.dataset.eventId || '';
|
||||
const resizeEndPayload: IResizeEndEventPayload = {
|
||||
eventId,
|
||||
element: this.targetEl,
|
||||
finalHeight: this.targetEl.offsetHeight
|
||||
};
|
||||
|
||||
eventBus.emit('resize:end', resizeEndPayload);
|
||||
}
|
||||
|
||||
private cleanupResizing(event: PointerEvent): void {
|
||||
this.restoreZIndex();
|
||||
this.releasePointer(event);
|
||||
|
||||
this.isResizing = false;
|
||||
this.targetEl = null;
|
||||
|
||||
document.documentElement.classList.remove('swp--resizing');
|
||||
}
|
||||
|
||||
private restoreZIndex(): void {
|
||||
if (!this.targetEl || this.prevZ === undefined) return;
|
||||
|
||||
const container = this.targetEl.closest<HTMLElement>('swp-event-group') ?? this.targetEl;
|
||||
container.style.zIndex = this.prevZ;
|
||||
this.prevZ = undefined;
|
||||
}
|
||||
|
||||
private releasePointer(event: PointerEvent): void {
|
||||
if (!this.pointerCaptured) return;
|
||||
|
||||
try {
|
||||
(event.target as Element).releasePointerCapture?.(event.pointerId);
|
||||
this.pointerCaptured = false;
|
||||
} catch (error) {
|
||||
console.warn('Pointer release failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
290
src/managers/ResizeManager.ts
Normal file
290
src/managers/ResizeManager.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
// Custom scroll management for calendar week container
|
||||
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { PositionUtils } from '../utils/PositionUtils';
|
||||
|
||||
/**
|
||||
* Manages scrolling functionality for the calendar using native scrollbars
|
||||
*/
|
||||
export class ScrollManager {
|
||||
private scrollableContent: HTMLElement | null = null;
|
||||
private calendarContainer: HTMLElement | null = null;
|
||||
private timeAxis: HTMLElement | null = null;
|
||||
private calendarHeader: HTMLElement | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private positionUtils: PositionUtils;
|
||||
|
||||
constructor(positionUtils: PositionUtils) {
|
||||
this.positionUtils = positionUtils;
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.subscribeToEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to initialize scroll after grid is rendered
|
||||
*/
|
||||
public initialize(): void {
|
||||
this.setupScrolling();
|
||||
}
|
||||
|
||||
private subscribeToEvents(): void {
|
||||
// Handle navigation animation completion - sync time axis position
|
||||
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => {
|
||||
this.syncTimeAxisPosition();
|
||||
this.setupScrolling();
|
||||
});
|
||||
|
||||
// Handle all-day row height changes
|
||||
eventBus.on('header:height-changed', () => {
|
||||
this.updateScrollableHeight();
|
||||
});
|
||||
|
||||
// Handle header ready - refresh header reference and re-sync
|
||||
eventBus.on('header:ready', () => {
|
||||
this.calendarHeader = document.querySelector('swp-calendar-header');
|
||||
if (this.scrollableContent && this.calendarHeader) {
|
||||
this.setupHorizontalScrollSynchronization();
|
||||
this.syncCalendarHeaderPosition(); // Immediately sync position
|
||||
}
|
||||
this.updateScrollableHeight(); // Update height calculations
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
this.updateScrollableHeight();
|
||||
});
|
||||
|
||||
// Listen for scroll to event time requests
|
||||
eventBus.on('scroll:to-event-time', (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { eventStartTime } = customEvent.detail;
|
||||
|
||||
if (eventStartTime) {
|
||||
this.scrollToEventTime(eventStartTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup scrolling functionality after grid is rendered
|
||||
*/
|
||||
private setupScrolling(): void {
|
||||
this.findElements();
|
||||
|
||||
if (this.scrollableContent && this.calendarContainer) {
|
||||
this.setupResizeObserver();
|
||||
this.updateScrollableHeight();
|
||||
this.setupScrollSynchronization();
|
||||
}
|
||||
|
||||
// Setup horizontal scrolling synchronization
|
||||
if (this.scrollableContent && this.calendarHeader) {
|
||||
this.setupHorizontalScrollSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find DOM elements needed for scrolling
|
||||
*/
|
||||
private findElements(): void {
|
||||
this.scrollableContent = document.querySelector('swp-scrollable-content');
|
||||
this.calendarContainer = document.querySelector('swp-calendar-container');
|
||||
this.timeAxis = document.querySelector('swp-time-axis');
|
||||
this.calendarHeader = document.querySelector('swp-calendar-header');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to specific position
|
||||
*/
|
||||
scrollTo(scrollTop: number): void {
|
||||
if (!this.scrollableContent) return;
|
||||
|
||||
this.scrollableContent.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to specific hour using PositionUtils
|
||||
*/
|
||||
scrollToHour(hour: number): void {
|
||||
// Create time string for the hour
|
||||
const timeString = `${hour.toString().padStart(2, '0')}:00`;
|
||||
const scrollTop = this.positionUtils.timeToPixels(timeString);
|
||||
|
||||
this.scrollTo(scrollTop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to specific event time
|
||||
* @param eventStartTime ISO string of event start time
|
||||
*/
|
||||
scrollToEventTime(eventStartTime: string): void {
|
||||
try {
|
||||
const eventDate = new Date(eventStartTime);
|
||||
const eventHour = eventDate.getHours();
|
||||
const eventMinutes = eventDate.getMinutes();
|
||||
|
||||
// Convert to decimal hour (e.g., 14:30 becomes 14.5)
|
||||
const decimalHour = eventHour + (eventMinutes / 60);
|
||||
|
||||
this.scrollToHour(decimalHour);
|
||||
} catch (error) {
|
||||
console.warn('ScrollManager: Failed to scroll to event time:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup ResizeObserver to monitor container size changes
|
||||
*/
|
||||
private setupResizeObserver(): void {
|
||||
if (!this.calendarContainer) return;
|
||||
|
||||
// Clean up existing observer
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
this.updateScrollableHeight();
|
||||
}
|
||||
});
|
||||
|
||||
this.resizeObserver.observe(this.calendarContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate and update scrollable content height dynamically
|
||||
*/
|
||||
private updateScrollableHeight(): void {
|
||||
if (!this.scrollableContent || !this.calendarContainer) return;
|
||||
|
||||
// Get calendar container height
|
||||
const containerRect = this.calendarContainer.getBoundingClientRect();
|
||||
|
||||
// Find navigation height
|
||||
const navigation = document.querySelector('swp-calendar-nav');
|
||||
const navHeight = navigation ? navigation.getBoundingClientRect().height : 0;
|
||||
|
||||
// Find calendar header height
|
||||
const calendarHeaderElement = document.querySelector('swp-calendar-header');
|
||||
const headerHeight = calendarHeaderElement ? calendarHeaderElement.getBoundingClientRect().height : 80;
|
||||
|
||||
// Calculate available height for scrollable content
|
||||
const availableHeight = containerRect.height - headerHeight;
|
||||
|
||||
// Calculate available width (container width minus time-axis)
|
||||
const availableWidth = containerRect.width - 60; // 60px time-axis
|
||||
|
||||
// Set the height and width on scrollable content
|
||||
if (availableHeight > 0) {
|
||||
this.scrollableContent.style.height = `${availableHeight}px`;
|
||||
}
|
||||
if (availableWidth > 0) {
|
||||
this.scrollableContent.style.width = `${availableWidth}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup scroll synchronization between scrollable content and time axis
|
||||
*/
|
||||
private setupScrollSynchronization(): void {
|
||||
if (!this.scrollableContent || !this.timeAxis) return;
|
||||
|
||||
// Throttle scroll events for better performance
|
||||
let scrollTimeout: number | null = null;
|
||||
|
||||
this.scrollableContent.addEventListener('scroll', () => {
|
||||
if (scrollTimeout) {
|
||||
cancelAnimationFrame(scrollTimeout);
|
||||
}
|
||||
|
||||
scrollTimeout = requestAnimationFrame(() => {
|
||||
this.syncTimeAxisPosition();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize time axis position with scrollable content
|
||||
*/
|
||||
private syncTimeAxisPosition(): void {
|
||||
if (!this.scrollableContent || !this.timeAxis) return;
|
||||
|
||||
const scrollTop = this.scrollableContent.scrollTop;
|
||||
const timeAxisContent = this.timeAxis.querySelector('swp-time-axis-content');
|
||||
|
||||
if (timeAxisContent) {
|
||||
// Use transform for smooth performance
|
||||
(timeAxisContent as HTMLElement).style.transform = `translateY(-${scrollTop}px)`;
|
||||
|
||||
// Debug logging (can be removed later)
|
||||
if (scrollTop % 100 === 0) { // Only log every 100px to avoid spam
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup horizontal scroll synchronization between scrollable content and calendar header
|
||||
*/
|
||||
private setupHorizontalScrollSynchronization(): void {
|
||||
if (!this.scrollableContent || !this.calendarHeader) return;
|
||||
|
||||
|
||||
// Listen to horizontal scroll events
|
||||
this.scrollableContent.addEventListener('scroll', () => {
|
||||
this.syncCalendarHeaderPosition();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize calendar header position with scrollable content horizontal scroll
|
||||
*/
|
||||
private syncCalendarHeaderPosition(): void {
|
||||
if (!this.scrollableContent || !this.calendarHeader) return;
|
||||
|
||||
const scrollLeft = this.scrollableContent.scrollLeft;
|
||||
|
||||
// Use transform for smooth performance
|
||||
this.calendarHeader.style.transform = `translateX(-${scrollLeft}px)`;
|
||||
|
||||
// Debug logging (can be removed later)
|
||||
if (scrollLeft % 100 === 0) { // Only log every 100px to avoid spam
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
// Work hours management for per-column scheduling
|
||||
|
||||
import { DateService } from '../utils/DateService';
|
||||
import { Configuration } from '../configurations/CalendarConfig';
|
||||
import { PositionUtils } from '../utils/PositionUtils';
|
||||
|
||||
/**
|
||||
* Work hours for a specific day
|
||||
*/
|
||||
export interface IDayWorkHours {
|
||||
start: number; // Hour (0-23)
|
||||
end: number; // Hour (0-23)
|
||||
}
|
||||
|
||||
/**
|
||||
* Work schedule configuration
|
||||
*/
|
||||
export interface IWorkScheduleConfig {
|
||||
weeklyDefault: {
|
||||
monday: IDayWorkHours | 'off';
|
||||
tuesday: IDayWorkHours | 'off';
|
||||
wednesday: IDayWorkHours | 'off';
|
||||
thursday: IDayWorkHours | 'off';
|
||||
friday: IDayWorkHours | 'off';
|
||||
saturday: IDayWorkHours | 'off';
|
||||
sunday: IDayWorkHours | 'off';
|
||||
};
|
||||
dateOverrides: {
|
||||
[dateString: string]: IDayWorkHours | 'off'; // YYYY-MM-DD format
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages work hours scheduling with weekly defaults and date-specific overrides
|
||||
*/
|
||||
export class WorkHoursManager {
|
||||
private dateService: DateService;
|
||||
private config: Configuration;
|
||||
private positionUtils: PositionUtils;
|
||||
private workSchedule: IWorkScheduleConfig;
|
||||
|
||||
constructor(dateService: DateService, config: Configuration, positionUtils: PositionUtils) {
|
||||
this.dateService = dateService;
|
||||
this.config = config;
|
||||
this.positionUtils = positionUtils;
|
||||
|
||||
// Default work schedule - will be loaded from JSON later
|
||||
this.workSchedule = {
|
||||
weeklyDefault: {
|
||||
monday: { start: 9, end: 17 },
|
||||
tuesday: { start: 9, end: 17 },
|
||||
wednesday: { start: 9, end: 17 },
|
||||
thursday: { start: 9, end: 17 },
|
||||
friday: { start: 9, end: 15 },
|
||||
saturday: 'off',
|
||||
sunday: 'off'
|
||||
},
|
||||
dateOverrides: {
|
||||
'2025-01-20': { start: 10, end: 16 },
|
||||
'2025-01-21': { start: 8, end: 14 },
|
||||
'2025-01-22': 'off'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get work hours for a specific date
|
||||
*/
|
||||
getWorkHoursForDate(date: Date): IDayWorkHours | 'off' {
|
||||
const dateString = this.dateService.formatISODate(date);
|
||||
|
||||
// Check for date-specific override first
|
||||
if (this.workSchedule.dateOverrides[dateString]) {
|
||||
return this.workSchedule.dateOverrides[dateString];
|
||||
}
|
||||
|
||||
// Fall back to weekly default
|
||||
const dayName = this.getDayName(date);
|
||||
return this.workSchedule.weeklyDefault[dayName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get work hours for multiple dates (used by GridManager)
|
||||
*/
|
||||
getWorkHoursForDateRange(dates: Date[]): Map<string, IDayWorkHours | 'off'> {
|
||||
const workHoursMap = new Map<string, IDayWorkHours | 'off'>();
|
||||
|
||||
dates.forEach(date => {
|
||||
const dateString = this.dateService.formatISODate(date);
|
||||
const workHours = this.getWorkHoursForDate(date);
|
||||
workHoursMap.set(dateString, workHours);
|
||||
});
|
||||
|
||||
return workHoursMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate CSS custom properties for non-work hour overlays using PositionUtils
|
||||
*/
|
||||
calculateNonWorkHoursStyle(workHours: IDayWorkHours | 'off'): { beforeWorkHeight: number; afterWorkTop: number } | null {
|
||||
if (workHours === 'off') {
|
||||
return null; // Full day will be colored via CSS background
|
||||
}
|
||||
|
||||
const gridSettings = this.config.gridSettings;
|
||||
const dayStartHour = gridSettings.dayStartHour;
|
||||
const hourHeight = gridSettings.hourHeight;
|
||||
|
||||
// Before work: from day start to work start
|
||||
const beforeWorkHeight = (workHours.start - dayStartHour) * hourHeight;
|
||||
|
||||
// After work: from work end to day end
|
||||
const afterWorkTop = (workHours.end - dayStartHour) * hourHeight;
|
||||
|
||||
return {
|
||||
beforeWorkHeight: Math.max(0, beforeWorkHeight),
|
||||
afterWorkTop: Math.max(0, afterWorkTop)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate CSS custom properties for work hours overlay using PositionUtils
|
||||
*/
|
||||
calculateWorkHoursStyle(workHours: IDayWorkHours | 'off'): { top: number; height: number } | null {
|
||||
if (workHours === 'off') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create dummy time strings for start and end of work hours
|
||||
const startTime = `${workHours.start.toString().padStart(2, '0')}:00`;
|
||||
const endTime = `${workHours.end.toString().padStart(2, '0')}:00`;
|
||||
|
||||
// Use PositionUtils for consistent position calculation
|
||||
const position = this.positionUtils.calculateEventPosition(startTime, endTime);
|
||||
|
||||
return { top: position.top, height: position.height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load work schedule from JSON (future implementation)
|
||||
*/
|
||||
async loadWorkSchedule(jsonData: IWorkScheduleConfig): Promise<void> {
|
||||
this.workSchedule = jsonData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current work schedule configuration
|
||||
*/
|
||||
getWorkSchedule(): IWorkScheduleConfig {
|
||||
return this.workSchedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Date to day name key
|
||||
*/
|
||||
private getDayName(date: Date): keyof IWorkScheduleConfig['weeklyDefault'] {
|
||||
const dayNames: (keyof IWorkScheduleConfig['weeklyDefault'])[] = [
|
||||
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'
|
||||
];
|
||||
return dayNames[date.getDay()];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue