Some ignored filles was missing

This commit is contained in:
Janus C. H. Knudsen 2026-02-03 00:02:25 +01:00
parent 7db22245e2
commit fd5ab6bc0d
268 changed files with 31970 additions and 4 deletions

91
wwwroot/js/managers/AllDayManager.d.ts vendored Normal file
View file

@ -0,0 +1,91 @@
import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
import { EventManager } from './EventManager';
import { DateService } from '../utils/DateService';
/**
* AllDayManager - Handles all-day row height animations and management
* Uses AllDayLayoutEngine for all overlap detection and layout calculation
*/
export declare class AllDayManager {
private allDayEventRenderer;
private eventManager;
private dateService;
private layoutEngine;
private currentAllDayEvents;
private currentWeekDates;
private isExpanded;
private actualRowCount;
constructor(eventManager: EventManager, allDayEventRenderer: AllDayEventRenderer, dateService: DateService);
/**
* Setup event listeners for drag conversions
*/
private setupEventListeners;
private getAllDayContainer;
private getCalendarHeader;
private getHeaderSpacer;
/**
* Read current max row from DOM elements
* Excludes events marked as removing (data-removing attribute)
*/
private getMaxRowFromDOM;
/**
* Get current gridArea for an event from DOM
*/
private getGridAreaFromDOM;
/**
* Count events in a specific column by reading DOM
*/
private countEventsInColumnFromDOM;
/**
* Calculate all-day height based on number of rows
*/
private calculateAllDayHeight;
/**
* Check current all-day events and animate to correct height
* Reads max row directly from DOM elements
*/
checkAndAnimateAllDayHeight(): void;
/**
* Animate all-day container to specific number of rows
*/
animateToRows(targetRows: number): void;
/**
* 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;
private handleConvertToAllDay;
/**
* Handle drag move for all-day events - SPECIALIZED FOR ALL-DAY CONTAINER
*/
private handleColumnChange;
private fadeOutAndRemove;
/**
* Handle timed all-day conversion on drop
*/
private handleTimedToAllDayDrop;
/**
* Handle all-day all-day drop (moving within header)
*/
private handleDragEnd;
/**
* Update chevron button visibility and state
*/
private updateChevronButton;
/**
* Toggle between expanded and collapsed state
*/
private toggleExpanded;
/**
* Count number of events in a specific column using IColumnBounds
* Reads directly from DOM elements
*/
private countEventsInColumn;
/**
* Update overflow indicators for collapsed state
*/
private updateOverflowIndicators;
/**
* Clear overflow indicators and restore normal state
*/
private clearOverflowIndicators;
}

View file

@ -0,0 +1,528 @@
// All-day row height management and animations
import { eventBus } from '../core/EventBus';
import { ALL_DAY_CONSTANTS } from '../configurations/CalendarConfig';
import { AllDayLayoutEngine } from '../utils/AllDayLayoutEngine';
import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
import { CoreEvents } from '../constants/CoreEvents';
/**
* AllDayManager - Handles all-day row height animations and management
* Uses AllDayLayoutEngine for all overlap detection and layout calculation
*/
export class AllDayManager {
constructor(eventManager, allDayEventRenderer, dateService) {
this.layoutEngine = null;
// State tracking for layout calculation
this.currentAllDayEvents = [];
this.currentWeekDates = [];
// Expand/collapse state
this.isExpanded = false;
this.actualRowCount = 0;
this.eventManager = eventManager;
this.allDayEventRenderer = allDayEventRenderer;
this.dateService = dateService;
// 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
*/
setupEventListeners() {
eventBus.on('drag:mouseenter-header', (event) => {
const payload = event.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.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 = event.detail;
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
this.allDayEventRenderer.handleDragStart(payload);
});
eventBus.on('drag:column-change', (event) => {
let payload = event.detail;
if (!payload.draggedClone?.hasAttribute('data-allday')) {
return;
}
this.handleColumnChange(payload);
});
eventBus.on('drag:end', (event) => {
let dragEndPayload = event.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.currentWeekDates);
// 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.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) => {
let headerReadyEventPayload = event.detail;
let startDate = new Date(headerReadyEventPayload.headerElements.at(0).date);
let endDate = new Date(headerReadyEventPayload.headerElements.at(-1).date);
let events = 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) => {
this.allDayEventRenderer.handleViewChanged(event);
});
}
getAllDayContainer() {
return document.querySelector('swp-calendar-header swp-allday-container');
}
getCalendarHeader() {
return document.querySelector('swp-calendar-header');
}
getHeaderSpacer() {
return document.querySelector('swp-header-spacer');
}
/**
* Read current max row from DOM elements
* Excludes events marked as removing (data-removing attribute)
*/
getMaxRowFromDOM() {
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) => {
const htmlElement = element;
const row = parseInt(htmlElement.style.gridRow) || 1;
maxRow = Math.max(maxRow, row);
});
return maxRow;
}
/**
* Get current gridArea for an event from DOM
*/
getGridAreaFromDOM(eventId) {
const container = this.getAllDayContainer();
if (!container)
return null;
const element = container.querySelector(`[data-event-id="${eventId}"]`);
return element?.style.gridArea || null;
}
/**
* Count events in a specific column by reading DOM
*/
countEventsInColumnFromDOM(columnIndex) {
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) => {
const htmlElement = element;
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
*/
calculateAllDayHeight(targetRows) {
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
*/
checkAndAnimateAllDayHeight() {
// 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
*/
animateToRows(targetRows) {
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
*/
calculateAllDayEventsLayout(events, dayHeaders) {
// Store current state
this.currentAllDayEvents = events;
this.currentWeekDates = dayHeaders;
// Initialize layout engine with provided week dates
let layoutEngine = new AllDayLayoutEngine(dayHeaders.map(column => column.date));
// Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly
return layoutEngine.calculateLayout(events);
}
handleConvertToAllDay(payload) {
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
*/
handleColumnChange(dragColumnChangeEventPayload) {
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}`;
}
fadeOutAndRemove(element) {
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
*/
async handleTimedToAllDayDrop(dragEndEvent) {
if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column)
return;
const clone = dragEndEvent.draggedClone;
const eventId = clone.eventId.replace('clone-', '');
const targetDate = dragEndEvent.finalPosition.column.date;
console.log('🔄 AllDayManager: Converting timed event to all-day', { eventId, targetDate });
// 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);
// Update event in repository
await this.eventManager.updateEvent(eventId, {
start: newStart,
end: newEnd,
allDay: true
});
// Remove original timed event
this.fadeOutAndRemove(dragEndEvent.originalElement);
// Add to current all-day events and recalculate layout
const newEvent = {
id: eventId,
title: clone.title,
start: newStart,
end: newEnd,
type: clone.type,
allDay: true,
syncStatus: 'synced'
};
const updatedEvents = [...this.currentAllDayEvents, newEvent];
const newLayouts = this.calculateAllDayEventsLayout(updatedEvents, this.currentWeekDates);
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
// Animate height
this.checkAndAnimateAllDayHeight();
}
/**
* Handle all-day all-day drop (moving within header)
*/
async handleDragEnd(dragEndEvent) {
if (!dragEndEvent.draggedClone || !dragEndEvent.finalPosition.column)
return;
const clone = dragEndEvent.draggedClone;
const eventId = clone.eventId.replace('clone-', '');
const targetDate = dragEndEvent.finalPosition.column.date;
// 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);
// Update event in repository
await this.eventManager.updateEvent(eventId, {
start: newStart,
end: newEnd,
allDay: true
});
// 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.currentWeekDates);
this.allDayEventRenderer.renderAllDayEventsForPeriod(newLayouts);
// Animate height - this also handles overflow classes!
this.checkAndAnimateAllDayHeight();
}
/**
* Update chevron button visibility and state
*/
updateChevronButton(show) {
const headerSpacer = this.getHeaderSpacer();
if (!headerSpacer)
return;
let chevron = headerSpacer.querySelector('.allday-chevron');
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
*/
toggleExpanded() {
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
*/
countEventsInColumn(columnBounds) {
return this.countEventsInColumnFromDOM(columnBounds.index);
}
/**
* Update overflow indicators for collapsed state
*/
updateOverflowIndicators() {
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}"]`);
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
*/
clearOverflowIndicators() {
const container = this.getAllDayContainer();
if (!container)
return;
// Remove all overflow indicator elements
container.querySelectorAll('.max-event-indicator').forEach((element) => {
element.remove();
});
}
}
//# sourceMappingURL=AllDayManager.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,45 @@
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 declare class CalendarManager {
private eventBus;
private eventManager;
private gridManager;
private eventRenderer;
private scrollManager;
private config;
private currentView;
private currentDate;
private isInitialized;
constructor(eventBus: IEventBus, eventManager: EventManager, gridManager: GridManager, eventRenderingService: EventRenderingService, scrollManager: ScrollManager, config: Configuration);
/**
* Initialize calendar system using simple direct calls
*/
initialize(): Promise<void>;
/**
* Skift calendar view (dag/uge/måned)
*/
setView(view: CalendarView): void;
/**
* Sæt aktuel dato
*/
setCurrentDate(date: Date): void;
/**
* Setup event listeners for at håndtere events fra andre managers
*/
private setupEventListeners;
/**
* Calculate the current period based on view and date
*/
private calculateCurrentPeriod;
/**
* Handle workweek configuration changes
*/
private handleWorkweekChange;
}

View file

@ -0,0 +1,145 @@
import { CoreEvents } from '../constants/CoreEvents';
/**
* CalendarManager - Main coordinator for all calendar managers
*/
export class CalendarManager {
constructor(eventBus, eventManager, gridManager, eventRenderingService, scrollManager, config) {
this.currentView = 'week';
this.currentDate = new Date();
this.isInitialized = false;
this.eventBus = eventBus;
this.eventManager = eventManager;
this.gridManager = gridManager;
this.eventRenderer = eventRenderingService;
this.scrollManager = scrollManager;
this.config = config;
this.setupEventListeners();
}
/**
* Initialize calendar system using simple direct calls
*/
async initialize() {
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)
*/
setView(view) {
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
*/
setCurrentDate(date) {
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
*/
setupEventListeners() {
// Listen for workweek changes only
this.eventBus.on(CoreEvents.WORKWEEK_CHANGED, (event) => {
const customEvent = event;
this.handleWorkweekChange();
});
}
/**
* Calculate the current period based on view and date
*/
calculateCurrentPeriod() {
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
*/
handleWorkweekChange() {
// Simply relay the event - workweek info is in the WORKWEEK_CHANGED event
this.eventBus.emit('workweek:header-update', {
currentDate: this.currentDate,
currentView: this.currentView
});
}
}
//# sourceMappingURL=CalendarManager.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"CalendarManager.js","sourceRoot":"","sources":["../../../src/managers/CalendarManager.ts"],"names":[],"mappings":"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"}

222
wwwroot/js/managers/DragDropManager.d.ts vendored Normal file
View file

@ -0,0 +1,222 @@
/**
* DragDropManager - Advanced drag-and-drop system with smooth animations and event type conversion
*
* ARCHITECTURE OVERVIEW:
* =====================
* DragDropManager provides a sophisticated drag-and-drop system for calendar events that supports:
* - Smooth animated dragging with requestAnimationFrame
* - Automatic event type conversion (timed events all-day events)
* - Scroll compensation during edge scrolling
* - Grid snapping for precise event placement
* - Column detection and change tracking
*
* KEY FEATURES:
* =============
* 1. DRAG DETECTION
* - Movement threshold (5px) to distinguish clicks from drags
* - Immediate visual feedback with cloned element
* - Mouse offset tracking for natural drag feel
*
* 2. SMOOTH ANIMATION
* - Uses requestAnimationFrame for 60fps animations
* - Interpolated movement (30% per frame) for smooth transitions
* - Continuous drag:move events for real-time updates
*
* 3. EVENT TYPE CONVERSION
* - Timed All-day: When dragging into calendar header
* - All-day Timed: When dragging into day columns
* - Automatic clone replacement with appropriate element type
*
* 4. SCROLL COMPENSATION
* - Tracks scroll delta during edge-scrolling
* - Compensates dragged element position during scroll
* - Prevents visual "jumping" when scrolling while dragging
*
* 5. GRID SNAPPING
* - Snaps to time grid on mouse up
* - Uses PositionUtils for consistent positioning
* - Accounts for mouse offset within event
*
* STATE MANAGEMENT:
* =================
* Mouse Tracking:
* - mouseDownPosition: Initial click position
* - currentMousePosition: Latest mouse position
* - mouseOffset: Click offset within event (for natural dragging)
*
* Drag State:
* - originalElement: Source event being dragged
* - draggedClone: Animated clone following mouse
* - currentColumn: Column mouse is currently over
* - previousColumn: Last column (for detecting changes)
* - isDragStarted: Whether drag threshold exceeded
*
* Scroll State:
* - scrollDeltaY: Accumulated scroll offset during drag
* - lastScrollTop: Previous scroll position
* - isScrollCompensating: Whether edge-scroll is active
*
* Animation State:
* - dragAnimationId: requestAnimationFrame ID
* - targetY: Desired position for smooth interpolation
* - currentY: Current interpolated position
*
* EVENT FLOW:
* ===========
* 1. Mouse Down (handleMouseDown)
* Store originalElement and mouse offset
* Wait for movement
*
* 2. Mouse Move (handleMouseMove)
* Check movement threshold
* Initialize drag if threshold exceeded (initializeDrag)
* Create clone
* Emit drag:start
* Start animation loop
* Continue drag (continueDrag)
* Calculate target position with scroll compensation
* Update animation target
* Detect column changes (detectColumnChange)
* Emit drag:column-change
*
* 3. Animation Loop (animateDrag)
* Interpolate currentY toward targetY
* Emit drag:move on each frame
* Schedule next frame until target reached
*
* 4. Event Type Conversion
* Entering header (handleHeaderMouseEnter)
* Emit drag:mouseenter-header
* AllDayManager creates all-day clone
* Entering column (handleColumnMouseEnter)
* Emit drag:mouseenter-column
* EventRenderingService creates timed clone
*
* 5. Mouse Up (handleMouseUp)
* Stop animation
* Snap to grid
* Detect drop target (header or column)
* Emit drag:end with final position
* Cleanup drag state
*
* SCROLL COMPENSATION SYSTEM:
* ===========================
* Problem: When EdgeScrollManager scrolls the grid during drag, the dragged element
* can appear to "jump" because the mouse position stays the same but the
* coordinate system (scrollable content) has moved.
*
* Solution: Track cumulative scroll delta and add it to mouse position calculations
*
* Flow:
* 1. EdgeScrollManager starts scrolling emit edgescroll:started
* 2. DragDropManager sets isScrollCompensating = true
* 3. On each scroll event:
* Calculate scrollDelta = currentScrollTop - lastScrollTop
* Accumulate into scrollDeltaY
* Call continueDrag with adjusted position
* 4. continueDrag adds scrollDeltaY to mouse Y coordinate
* 5. On event conversion, reset scrollDeltaY (new clone, new coordinate system)
*
* PERFORMANCE OPTIMIZATIONS:
* ==========================
* - Uses ColumnDetectionUtils cache for fast column lookups
* - Single requestAnimationFrame loop (not per-mousemove)
* - Interpolated animation reduces update frequency
* - Passive scroll listeners
* - Event delegation for header/column detection
*
* USAGE:
* ======
* const dragDropManager = new DragDropManager(eventBus, positionUtils);
* // Automatically attaches event listeners and manages drag lifecycle
* // Other managers listen to drag:start, drag:move, drag:end, etc.
*/
import { IEventBus } from '../types/CalendarTypes';
import { PositionUtils } from '../utils/PositionUtils';
export declare class DragDropManager {
private eventBus;
private mouseDownPosition;
private currentMousePosition;
private mouseOffset;
private originalElement;
private draggedClone;
private currentColumn;
private previousColumn;
private originalSourceColumn;
private isDragStarted;
private readonly dragThreshold;
private scrollableContent;
private scrollDeltaY;
private lastScrollTop;
private isScrollCompensating;
private dragAnimationId;
private targetY;
private currentY;
private targetColumn;
private positionUtils;
constructor(eventBus: IEventBus, positionUtils: PositionUtils);
/**
* Initialize with optimized event listener setup
*/
private init;
private handleGridRendered;
private handleMouseDown;
private handleMouseMove;
/**
* Try to initialize drag based on movement threshold
* Returns true if drag was initialized, false if not enough movement
*/
private initializeDrag;
private continueDrag;
/**
* Detect column change and emit event
*/
private detectColumnChange;
/**
* Optimized mouse up handler with consolidated cleanup
*/
private handleMouseUp;
private cleanupAllClones;
/**
* Cancel drag operation when mouse leaves grid container
* Animates clone back to original position before cleanup
*/
private cancelDrag;
/**
* Optimized snap position calculation using PositionUtils
*/
private calculateSnapPosition;
/**
* Smooth drag animation using requestAnimationFrame
* Emits drag:move events with current draggedClone reference on each frame
*/
private animateDrag;
/**
* Handle scroll during drag - update scrollDeltaY and call continueDrag
*/
private handleScroll;
/**
* Stop drag animation
*/
private stopDragAnimation;
/**
* Clean up drag state
*/
private cleanupDragState;
/**
* Detect drop target - whether dropped in swp-day-column or swp-day-header
*/
private detectDropTarget;
/**
* Handle mouse enter on calendar header - simplified using native events
*/
private handleHeaderMouseEnter;
/**
* Handle mouse enter on day column - for converting all-day to timed events
*/
private handleColumnMouseEnter;
/**
* Handle mouse leave from calendar header - simplified using native events
*/
private handleHeaderMouseLeave;
}

View file

@ -0,0 +1,626 @@
/**
* DragDropManager - Advanced drag-and-drop system with smooth animations and event type conversion
*
* ARCHITECTURE OVERVIEW:
* =====================
* DragDropManager provides a sophisticated drag-and-drop system for calendar events that supports:
* - Smooth animated dragging with requestAnimationFrame
* - Automatic event type conversion (timed events all-day events)
* - Scroll compensation during edge scrolling
* - Grid snapping for precise event placement
* - Column detection and change tracking
*
* KEY FEATURES:
* =============
* 1. DRAG DETECTION
* - Movement threshold (5px) to distinguish clicks from drags
* - Immediate visual feedback with cloned element
* - Mouse offset tracking for natural drag feel
*
* 2. SMOOTH ANIMATION
* - Uses requestAnimationFrame for 60fps animations
* - Interpolated movement (30% per frame) for smooth transitions
* - Continuous drag:move events for real-time updates
*
* 3. EVENT TYPE CONVERSION
* - Timed All-day: When dragging into calendar header
* - All-day Timed: When dragging into day columns
* - Automatic clone replacement with appropriate element type
*
* 4. SCROLL COMPENSATION
* - Tracks scroll delta during edge-scrolling
* - Compensates dragged element position during scroll
* - Prevents visual "jumping" when scrolling while dragging
*
* 5. GRID SNAPPING
* - Snaps to time grid on mouse up
* - Uses PositionUtils for consistent positioning
* - Accounts for mouse offset within event
*
* STATE MANAGEMENT:
* =================
* Mouse Tracking:
* - mouseDownPosition: Initial click position
* - currentMousePosition: Latest mouse position
* - mouseOffset: Click offset within event (for natural dragging)
*
* Drag State:
* - originalElement: Source event being dragged
* - draggedClone: Animated clone following mouse
* - currentColumn: Column mouse is currently over
* - previousColumn: Last column (for detecting changes)
* - isDragStarted: Whether drag threshold exceeded
*
* Scroll State:
* - scrollDeltaY: Accumulated scroll offset during drag
* - lastScrollTop: Previous scroll position
* - isScrollCompensating: Whether edge-scroll is active
*
* Animation State:
* - dragAnimationId: requestAnimationFrame ID
* - targetY: Desired position for smooth interpolation
* - currentY: Current interpolated position
*
* EVENT FLOW:
* ===========
* 1. Mouse Down (handleMouseDown)
* Store originalElement and mouse offset
* Wait for movement
*
* 2. Mouse Move (handleMouseMove)
* Check movement threshold
* Initialize drag if threshold exceeded (initializeDrag)
* Create clone
* Emit drag:start
* Start animation loop
* Continue drag (continueDrag)
* Calculate target position with scroll compensation
* Update animation target
* Detect column changes (detectColumnChange)
* Emit drag:column-change
*
* 3. Animation Loop (animateDrag)
* Interpolate currentY toward targetY
* Emit drag:move on each frame
* Schedule next frame until target reached
*
* 4. Event Type Conversion
* Entering header (handleHeaderMouseEnter)
* Emit drag:mouseenter-header
* AllDayManager creates all-day clone
* Entering column (handleColumnMouseEnter)
* Emit drag:mouseenter-column
* EventRenderingService creates timed clone
*
* 5. Mouse Up (handleMouseUp)
* Stop animation
* Snap to grid
* Detect drop target (header or column)
* Emit drag:end with final position
* Cleanup drag state
*
* SCROLL COMPENSATION SYSTEM:
* ===========================
* Problem: When EdgeScrollManager scrolls the grid during drag, the dragged element
* can appear to "jump" because the mouse position stays the same but the
* coordinate system (scrollable content) has moved.
*
* Solution: Track cumulative scroll delta and add it to mouse position calculations
*
* Flow:
* 1. EdgeScrollManager starts scrolling emit edgescroll:started
* 2. DragDropManager sets isScrollCompensating = true
* 3. On each scroll event:
* Calculate scrollDelta = currentScrollTop - lastScrollTop
* Accumulate into scrollDeltaY
* Call continueDrag with adjusted position
* 4. continueDrag adds scrollDeltaY to mouse Y coordinate
* 5. On event conversion, reset scrollDeltaY (new clone, new coordinate system)
*
* PERFORMANCE OPTIMIZATIONS:
* ==========================
* - Uses ColumnDetectionUtils cache for fast column lookups
* - Single requestAnimationFrame loop (not per-mousemove)
* - Interpolated animation reduces update frequency
* - Passive scroll listeners
* - Event delegation for header/column detection
*
* USAGE:
* ======
* const dragDropManager = new DragDropManager(eventBus, positionUtils);
* // Automatically attaches event listeners and manages drag lifecycle
* // Other managers listen to drag:start, drag:move, drag:end, etc.
*/
import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
import { SwpEventElement } from '../elements/SwpEventElement';
import { CoreEvents } from '../constants/CoreEvents';
export class DragDropManager {
constructor(eventBus, positionUtils) {
// Mouse tracking with optimized state
this.mouseDownPosition = { x: 0, y: 0 };
this.currentMousePosition = { x: 0, y: 0 };
this.mouseOffset = { x: 0, y: 0 };
this.currentColumn = null;
this.previousColumn = null;
this.originalSourceColumn = null; // Track original start column
this.isDragStarted = false;
// Movement threshold to distinguish click from drag
this.dragThreshold = 5; // pixels
// Scroll compensation
this.scrollableContent = null;
this.scrollDeltaY = 0; // Current scroll delta to apply in continueDrag
this.lastScrollTop = 0; // Last scroll position for delta calculation
this.isScrollCompensating = false; // Track if scroll compensation is active
// Smooth drag animation
this.dragAnimationId = null;
this.targetY = 0;
this.currentY = 0;
this.targetColumn = null;
this.eventBus = eventBus;
this.positionUtils = positionUtils;
this.init();
}
/**
* Initialize with optimized event listener setup
*/
init() {
// Add event listeners
document.body.addEventListener('mousemove', this.handleMouseMove.bind(this));
document.body.addEventListener('mousedown', this.handleMouseDown.bind(this));
document.body.addEventListener('mouseup', this.handleMouseUp.bind(this));
const calendarContainer = document.querySelector('swp-calendar-container');
if (calendarContainer) {
calendarContainer.addEventListener('mouseleave', () => {
if (this.originalElement && this.isDragStarted) {
this.cancelDrag();
}
});
// Event delegation for header enter/leave
calendarContainer.addEventListener('mouseenter', (e) => {
const target = e.target;
if (target.closest('swp-calendar-header')) {
this.handleHeaderMouseEnter(e);
}
else if (target.closest('swp-day-column')) {
this.handleColumnMouseEnter(e);
}
}, true); // Use capture phase
calendarContainer.addEventListener('mouseleave', (e) => {
const target = e.target;
if (target.closest('swp-calendar-header')) {
this.handleHeaderMouseLeave(e);
}
// Don't handle swp-event mouseleave here - let mousemove handle it
}, true); // Use capture phase
}
// Initialize column bounds cache
ColumnDetectionUtils.updateColumnBoundsCache();
// Listen to resize events to update cache
window.addEventListener('resize', () => {
ColumnDetectionUtils.updateColumnBoundsCache();
});
// Listen to navigation events to update cache
this.eventBus.on('navigation:completed', () => {
ColumnDetectionUtils.updateColumnBoundsCache();
});
this.eventBus.on(CoreEvents.GRID_RENDERED, (event) => {
this.handleGridRendered(event);
});
// Listen to edge-scroll events to control scroll compensation
this.eventBus.on('edgescroll:started', () => {
this.isScrollCompensating = true;
// Gem nuværende scroll position for delta beregning
if (this.scrollableContent) {
this.lastScrollTop = this.scrollableContent.scrollTop;
}
});
this.eventBus.on('edgescroll:stopped', () => {
this.isScrollCompensating = false;
});
// Reset scrollDeltaY when event converts (new clone created)
this.eventBus.on('drag:mouseenter-header', () => {
this.scrollDeltaY = 0;
this.lastScrollTop = 0;
});
this.eventBus.on('drag:mouseenter-column', () => {
this.scrollDeltaY = 0;
this.lastScrollTop = 0;
});
}
handleGridRendered(event) {
this.scrollableContent = document.querySelector('swp-scrollable-content');
this.scrollableContent.addEventListener('scroll', this.handleScroll.bind(this), { passive: true });
}
handleMouseDown(event) {
// Clean up drag state first
this.cleanupDragState();
ColumnDetectionUtils.updateColumnBoundsCache();
//this.lastMousePosition = { x: event.clientX, y: event.clientY };
//this.initialMousePosition = { x: event.clientX, y: event.clientY };
// Check if mousedown is on an event
const target = event.target;
if (target.closest('swp-resize-handle'))
return;
let eventElement = target;
while (eventElement && eventElement.tagName !== 'SWP-GRID-CONTAINER') {
if (eventElement.tagName === 'SWP-EVENT' || eventElement.tagName === 'SWP-ALLDAY-EVENT') {
break;
}
eventElement = eventElement.parentElement;
if (!eventElement)
return;
}
if (eventElement) {
// Normal drag - prepare for potential dragging
this.originalElement = eventElement;
// Calculate mouse offset within event
const eventRect = eventElement.getBoundingClientRect();
this.mouseOffset = {
x: event.clientX - eventRect.left,
y: event.clientY - eventRect.top
};
this.mouseDownPosition = { x: event.clientX, y: event.clientY };
}
}
handleMouseMove(event) {
if (event.buttons === 1) {
// Always update mouse position from event
this.currentMousePosition = { x: event.clientX, y: event.clientY };
// Try to initialize drag if not started
if (!this.isDragStarted && this.originalElement) {
if (!this.initializeDrag(this.currentMousePosition)) {
return; // Not enough movement yet
}
}
// Continue drag if started (også under scroll - accumulatedScrollDelta kompenserer)
if (this.isDragStarted && this.originalElement && this.draggedClone) {
this.continueDrag(this.currentMousePosition);
this.detectColumnChange(this.currentMousePosition);
}
}
}
/**
* Try to initialize drag based on movement threshold
* Returns true if drag was initialized, false if not enough movement
*/
initializeDrag(currentPosition) {
const deltaX = Math.abs(currentPosition.x - this.mouseDownPosition.x);
const deltaY = Math.abs(currentPosition.y - this.mouseDownPosition.y);
const totalMovement = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (totalMovement < this.dragThreshold) {
return false; // Not enough movement
}
// Start drag
this.isDragStarted = true;
// Set high z-index on event-group if exists, otherwise on event itself
const eventGroup = this.originalElement.closest('swp-event-group');
if (eventGroup) {
eventGroup.style.zIndex = '9999';
}
else {
this.originalElement.style.zIndex = '9999';
}
const originalElement = this.originalElement;
this.currentColumn = ColumnDetectionUtils.getColumnBounds(currentPosition);
this.originalSourceColumn = this.currentColumn; // Store original source column at drag start
this.draggedClone = originalElement.createClone();
const dragStartPayload = {
originalElement: this.originalElement,
draggedClone: this.draggedClone,
mousePosition: this.mouseDownPosition,
mouseOffset: this.mouseOffset,
columnBounds: this.currentColumn
};
this.eventBus.emit('drag:start', dragStartPayload);
return true;
}
continueDrag(currentPosition) {
if (!this.draggedClone.hasAttribute("data-allday")) {
// Calculate raw position from mouse (no snapping)
const column = ColumnDetectionUtils.getColumnBounds(currentPosition);
if (column) {
// Calculate raw Y position relative to column (accounting for mouse offset)
const columnRect = column.boundingClientRect;
// Beregn position fra mus + scroll delta kompensation
const adjustedMouseY = currentPosition.y + this.scrollDeltaY;
const eventTopY = adjustedMouseY - columnRect.top - this.mouseOffset.y;
this.targetY = Math.max(0, eventTopY);
this.targetColumn = column;
// Start animation loop if not already running
if (this.dragAnimationId === null) {
this.currentY = parseFloat(this.draggedClone.style.top) || 0;
this.animateDrag();
}
}
}
}
/**
* Detect column change and emit event
*/
detectColumnChange(currentPosition) {
const newColumn = ColumnDetectionUtils.getColumnBounds(currentPosition);
if (newColumn == null)
return;
if (newColumn.index !== this.currentColumn?.index) {
this.previousColumn = this.currentColumn;
this.currentColumn = newColumn;
const dragColumnChangePayload = {
originalElement: this.originalElement,
draggedClone: this.draggedClone,
previousColumn: this.previousColumn,
newColumn,
mousePosition: currentPosition
};
this.eventBus.emit('drag:column-change', dragColumnChangePayload);
}
}
/**
* Optimized mouse up handler with consolidated cleanup
*/
handleMouseUp(event) {
this.stopDragAnimation();
if (this.originalElement) {
// Only emit drag:end if drag was actually started
if (this.isDragStarted) {
const mousePosition = { x: event.clientX, y: event.clientY };
// Snap to grid on mouse up (like ResizeHandleManager)
const column = ColumnDetectionUtils.getColumnBounds(mousePosition);
if (!column)
return;
// Get current position and snap it to grid
const snappedY = this.calculateSnapPosition(mousePosition.y, column);
// Update clone to snapped position immediately
if (this.draggedClone) {
this.draggedClone.style.top = `${snappedY}px`;
}
// Detect drop target (swp-day-column or swp-day-header)
const dropTarget = this.detectDropTarget(mousePosition);
if (!dropTarget)
throw "dropTarget is null";
const dragEndPayload = {
originalElement: this.originalElement,
draggedClone: this.draggedClone,
mousePosition,
originalSourceColumn: this.originalSourceColumn,
finalPosition: { column, snappedY }, // Where drag ended
target: dropTarget
};
this.eventBus.emit('drag:end', dragEndPayload);
this.cleanupDragState();
}
else {
// This was just a click - emit click event instead
this.eventBus.emit('event:click', {
clickedElement: this.originalElement,
mousePosition: { x: event.clientX, y: event.clientY }
});
}
}
}
// Add a cleanup method that finds and removes ALL clones
cleanupAllClones() {
// Remove clones from all possible locations
const allClones = document.querySelectorAll('[data-event-id^="clone"]');
if (allClones.length > 0) {
allClones.forEach(clone => clone.remove());
}
}
/**
* Cancel drag operation when mouse leaves grid container
* Animates clone back to original position before cleanup
*/
cancelDrag() {
if (!this.originalElement || !this.draggedClone)
return;
// Get current clone position
const cloneRect = this.draggedClone.getBoundingClientRect();
// Get original element position
const originalRect = this.originalElement.getBoundingClientRect();
// Calculate distance to animate
const deltaX = originalRect.left - cloneRect.left;
const deltaY = originalRect.top - cloneRect.top;
// Add transition for smooth animation
this.draggedClone.style.transition = 'transform 300ms ease-out';
this.draggedClone.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
// Wait for animation to complete, then cleanup
setTimeout(() => {
this.cleanupAllClones();
if (this.originalElement) {
this.originalElement.style.opacity = '';
this.originalElement.style.cursor = '';
}
this.eventBus.emit('drag:cancelled', {
originalElement: this.originalElement,
reason: 'mouse-left-grid'
});
this.cleanupDragState();
this.stopDragAnimation();
}, 300);
}
/**
* Optimized snap position calculation using PositionUtils
*/
calculateSnapPosition(mouseY, column) {
// Calculate where the event top would be (accounting for mouse offset)
const eventTopY = mouseY - this.mouseOffset.y;
// Snap the event top position, not the mouse position
const snappedY = this.positionUtils.getPositionFromCoordinate(eventTopY, column);
return Math.max(0, snappedY);
}
/**
* Smooth drag animation using requestAnimationFrame
* Emits drag:move events with current draggedClone reference on each frame
*/
animateDrag() {
if (!this.isDragStarted || !this.draggedClone || !this.targetColumn) {
this.dragAnimationId = null;
return;
}
// Smooth interpolation towards target
const diff = this.targetY - this.currentY;
const step = diff * 0.3; // 30% of distance per frame
// Update if difference is significant
if (Math.abs(diff) > 0.5) {
this.currentY += step;
// Emit drag:move event with current draggedClone reference
const dragMovePayload = {
originalElement: this.originalElement,
draggedClone: this.draggedClone, // Always uses current reference
mousePosition: this.currentMousePosition, // Use current mouse position!
snappedY: this.currentY,
columnBounds: this.targetColumn,
mouseOffset: this.mouseOffset
};
this.eventBus.emit('drag:move', dragMovePayload);
this.dragAnimationId = requestAnimationFrame(() => this.animateDrag());
}
else {
// Close enough - snap to target
this.currentY = this.targetY;
// Emit final position
const dragMovePayload = {
originalElement: this.originalElement,
draggedClone: this.draggedClone,
mousePosition: this.currentMousePosition, // Use current mouse position!
snappedY: this.currentY,
columnBounds: this.targetColumn,
mouseOffset: this.mouseOffset
};
this.eventBus.emit('drag:move', dragMovePayload);
this.dragAnimationId = null;
}
}
/**
* Handle scroll during drag - update scrollDeltaY and call continueDrag
*/
handleScroll() {
if (!this.isDragStarted || !this.draggedClone || !this.scrollableContent || !this.isScrollCompensating)
return;
const currentScrollTop = this.scrollableContent.scrollTop;
const scrollDelta = currentScrollTop - this.lastScrollTop;
// Gem scroll delta for continueDrag
this.scrollDeltaY += scrollDelta;
this.lastScrollTop = currentScrollTop;
// Kald continueDrag med nuværende mus position
this.continueDrag(this.currentMousePosition);
}
/**
* Stop drag animation
*/
stopDragAnimation() {
if (this.dragAnimationId !== null) {
cancelAnimationFrame(this.dragAnimationId);
this.dragAnimationId = null;
}
}
/**
* Clean up drag state
*/
cleanupDragState() {
this.previousColumn = null;
this.originalElement = null;
this.draggedClone = null;
this.currentColumn = null;
this.originalSourceColumn = null;
this.isDragStarted = false;
this.scrollDeltaY = 0;
this.lastScrollTop = 0;
}
/**
* Detect drop target - whether dropped in swp-day-column or swp-day-header
*/
detectDropTarget(position) {
// Traverse up the DOM tree to find the target container
let currentElement = this.draggedClone;
while (currentElement && currentElement !== document.body) {
if (currentElement.tagName === 'SWP-ALLDAY-CONTAINER') {
return 'swp-day-header';
}
if (currentElement.tagName === 'SWP-DAY-COLUMN') {
return 'swp-day-column';
}
currentElement = currentElement.parentElement;
}
return null;
}
/**
* Handle mouse enter on calendar header - simplified using native events
*/
handleHeaderMouseEnter(event) {
// Only handle if we're dragging a timed event (not all-day)
if (!this.isDragStarted || !this.draggedClone) {
return;
}
const position = { x: event.clientX, y: event.clientY };
const targetColumn = ColumnDetectionUtils.getColumnBounds(position);
if (targetColumn) {
const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone);
const dragMouseEnterPayload = {
targetColumn: targetColumn,
mousePosition: position,
originalElement: this.originalElement,
draggedClone: this.draggedClone,
calendarEvent: calendarEvent,
replaceClone: (newClone) => {
this.draggedClone = newClone;
this.dragAnimationId === null;
}
};
this.eventBus.emit('drag:mouseenter-header', dragMouseEnterPayload);
}
}
/**
* Handle mouse enter on day column - for converting all-day to timed events
*/
handleColumnMouseEnter(event) {
// Only handle if we're dragging an all-day event
if (!this.isDragStarted || !this.draggedClone || !this.draggedClone.hasAttribute('data-allday')) {
return;
}
const position = { x: event.clientX, y: event.clientY };
const targetColumn = ColumnDetectionUtils.getColumnBounds(position);
if (!targetColumn) {
return;
}
// Calculate snapped Y position
const snappedY = this.calculateSnapPosition(position.y, targetColumn);
// Extract ICalendarEvent from the dragged clone
const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone);
const dragMouseEnterPayload = {
targetColumn: targetColumn,
mousePosition: position,
snappedY: snappedY,
originalElement: this.originalElement,
draggedClone: this.draggedClone,
calendarEvent: calendarEvent,
replaceClone: (newClone) => {
this.draggedClone = newClone;
this.dragAnimationId === null;
this.stopDragAnimation();
}
};
this.eventBus.emit('drag:mouseenter-column', dragMouseEnterPayload);
}
/**
* Handle mouse leave from calendar header - simplified using native events
*/
handleHeaderMouseLeave(event) {
// Only handle if we're dragging an all-day event
if (!this.isDragStarted || !this.draggedClone || !this.draggedClone.hasAttribute("data-allday")) {
return;
}
const position = { x: event.clientX, y: event.clientY };
const targetColumn = ColumnDetectionUtils.getColumnBounds(position);
if (!targetColumn) {
return;
}
const dragMouseLeavePayload = {
targetDate: targetColumn.date,
mousePosition: position,
originalElement: this.originalElement,
draggedClone: this.draggedClone
};
this.eventBus.emit('drag:mouseleave-header', dragMouseLeavePayload);
}
}
//# sourceMappingURL=DragDropManager.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,31 @@
/**
* DragHoverManager - Handles event hover tracking
* Fully autonomous - listens to mouse events and manages hover state independently
*/
import { IEventBus } from '../types/CalendarTypes';
export declare class DragHoverManager {
private eventBus;
private isHoverTrackingActive;
private currentHoveredEvent;
private calendarContainer;
constructor(eventBus: IEventBus);
private init;
private setupEventListeners;
/**
* Handle mouse enter on swp-event - activate hover tracking
*/
private handleEventMouseEnter;
/**
* Check if mouse is still over the currently hovered event
*/
private checkEventHover;
/**
* Clear hover state
*/
private clearEventHover;
/**
* Deactivate hover tracking and clear any current hover
* Called via event bus when drag starts
*/
private deactivateTracking;
}

View file

@ -0,0 +1,101 @@
/**
* DragHoverManager - Handles event hover tracking
* Fully autonomous - listens to mouse events and manages hover state independently
*/
export class DragHoverManager {
constructor(eventBus) {
this.eventBus = eventBus;
this.isHoverTrackingActive = false;
this.currentHoveredEvent = null;
this.calendarContainer = null;
this.init();
}
init() {
// Wait for DOM to be ready
setTimeout(() => {
this.calendarContainer = document.querySelector('swp-calendar-container');
if (this.calendarContainer) {
this.setupEventListeners();
}
}, 100);
// Listen to drag start to deactivate hover tracking
this.eventBus.on('drag:start', () => {
this.deactivateTracking();
});
}
setupEventListeners() {
if (!this.calendarContainer)
return;
// Listen to mouseenter on events (using event delegation)
this.calendarContainer.addEventListener('mouseenter', (e) => {
const target = e.target;
const eventElement = target.closest('swp-event');
if (eventElement) {
this.handleEventMouseEnter(e, eventElement);
}
}, true); // Use capture phase
// Listen to mousemove globally to track when mouse leaves event bounds
document.body.addEventListener('mousemove', (e) => {
if (this.isHoverTrackingActive && e.buttons === 0) {
this.checkEventHover(e);
}
});
}
/**
* Handle mouse enter on swp-event - activate hover tracking
*/
handleEventMouseEnter(event, eventElement) {
// Only handle hover if mouse button is up
if (event.buttons === 0) {
// Clear any previous hover first
if (this.currentHoveredEvent && this.currentHoveredEvent !== eventElement) {
this.currentHoveredEvent.classList.remove('hover');
}
this.isHoverTrackingActive = true;
this.currentHoveredEvent = eventElement;
eventElement.classList.add('hover');
this.eventBus.emit('event:hover:start', { element: eventElement });
}
}
/**
* Check if mouse is still over the currently hovered event
*/
checkEventHover(event) {
// Only track hover when active and mouse button is up
if (!this.isHoverTrackingActive || !this.currentHoveredEvent)
return;
const rect = this.currentHoveredEvent.getBoundingClientRect();
const mouseX = event.clientX;
const mouseY = event.clientY;
// Check if mouse is still within the current hovered event
const isStillInside = mouseX >= rect.left && mouseX <= rect.right &&
mouseY >= rect.top && mouseY <= rect.bottom;
// If mouse left the event
if (!isStillInside) {
// Only disable tracking and clear if mouse is NOT pressed (allow resize to work)
if (event.buttons === 0) {
this.isHoverTrackingActive = false;
this.clearEventHover();
}
}
}
/**
* Clear hover state
*/
clearEventHover() {
if (this.currentHoveredEvent) {
this.currentHoveredEvent.classList.remove('hover');
this.eventBus.emit('event:hover:end', { element: this.currentHoveredEvent });
this.currentHoveredEvent = null;
}
}
/**
* Deactivate hover tracking and clear any current hover
* Called via event bus when drag starts
*/
deactivateTracking() {
this.isHoverTrackingActive = false;
this.clearEventHover();
}
}
//# sourceMappingURL=DragHoverManager.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"DragHoverManager.js","sourceRoot":"","sources":["../../../src/managers/DragHoverManager.ts"],"names":[],"mappings":"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"}

View file

@ -0,0 +1,30 @@
/**
* EdgeScrollManager - Auto-scroll when dragging near edges
* Uses time-based scrolling with 2-zone system for variable speed
*/
import { IEventBus } from '../types/CalendarTypes';
export declare class EdgeScrollManager {
private eventBus;
private scrollableContent;
private timeGrid;
private draggedClone;
private scrollRAF;
private mouseY;
private isDragging;
private isScrolling;
private lastTs;
private rect;
private initialScrollTop;
private scrollListener;
private readonly OUTER_ZONE;
private readonly INNER_ZONE;
private readonly SLOW_SPEED_PXS;
private readonly FAST_SPEED_PXS;
constructor(eventBus: IEventBus);
private init;
private subscribeToEvents;
private startDrag;
private stopDrag;
private handleScroll;
private scrollTick;
}

View file

@ -0,0 +1,191 @@
/**
* EdgeScrollManager - Auto-scroll when dragging near edges
* Uses time-based scrolling with 2-zone system for variable speed
*/
export class EdgeScrollManager {
constructor(eventBus) {
this.eventBus = eventBus;
this.scrollableContent = null;
this.timeGrid = null;
this.draggedClone = null;
this.scrollRAF = null;
this.mouseY = 0;
this.isDragging = false;
this.isScrolling = false; // Track if edge-scroll is active
this.lastTs = 0;
this.rect = null;
this.initialScrollTop = 0;
this.scrollListener = null;
// Constants - fixed values as per requirements
this.OUTER_ZONE = 100; // px from edge (slow zone)
this.INNER_ZONE = 50; // px from edge (fast zone)
this.SLOW_SPEED_PXS = 140; // px/sec in outer zone
this.FAST_SPEED_PXS = 640; // px/sec in inner zone
this.init();
}
init() {
// 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) => {
if (this.isDragging) {
this.mouseY = e.clientY;
}
});
this.subscribeToEvents();
}
subscribeToEvents() {
// Listen to drag events from DragDropManager
this.eventBus.on('drag:start', (event) => {
const payload = event.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();
});
}
startDrag() {
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));
}
}
stopDrag() {
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;
}
handleScroll() {
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', {});
}
}
scrollTick(ts) {
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();
}
}
}
}
//# sourceMappingURL=EdgeScrollManager.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,32 @@
/**
* EventFilterManager - Handles fuzzy search filtering of calendar events
* Uses Fuse.js for fuzzy matching (Apache 2.0 License)
*/
export declare class EventFilterManager {
private searchInput;
private allEvents;
private matchingEventIds;
private isFilterActive;
private frameRequest;
private fuse;
constructor();
private init;
private setupSearchListeners;
private subscribeToEvents;
private updateEventsList;
private handleSearchInput;
private applyFilter;
private clearFilter;
private updateVisualState;
/**
* Check if an event matches the current filter
*/
eventMatchesFilter(eventId: string): boolean;
/**
* Get current filter state
*/
getFilterState(): {
active: boolean;
matchingIds: string[];
};
}

View file

@ -0,0 +1,192 @@
/**
* 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 Fuse.js from npm
import Fuse from 'fuse.js';
export class EventFilterManager {
constructor() {
this.searchInput = null;
this.allEvents = [];
this.matchingEventIds = new Set();
this.isFilterActive = false;
this.frameRequest = null;
this.fuse = null;
// Wait for DOM to be ready before initializing
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
this.init();
});
}
else {
this.init();
}
}
init() {
// 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
}
setupSearchListeners() {
if (!this.searchInput)
return;
// Listen for input changes
this.searchInput.addEventListener('input', (e) => {
const query = e.target.value;
this.handleSearchInput(query);
});
// Listen for escape key
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.clearFilter();
}
});
}
subscribeToEvents() {
// Listen for events data updates
eventBus.on(CoreEvents.EVENTS_RENDERED, (e) => {
const detail = e.detail;
if (detail?.events) {
this.updateEventsList(detail.events);
}
});
}
updateEventsList(events) {
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);
}
}
handleSearchInput(query) {
// 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);
}
});
}
applyFilter(query) {
if (!this.fuse) {
return;
}
// Perform fuzzy search
const results = this.fuse.search(query);
// Extract matching event IDs
this.matchingEventIds.clear();
results.forEach((result) => {
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)
});
}
clearFilter() {
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: []
});
}
updateVisualState() {
// 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
*/
eventMatchesFilter(eventId) {
if (!this.isFilterActive) {
return true; // No filter active, all events match
}
return this.matchingEventIds.has(eventId);
}
/**
* Get current filter state
*/
getFilterState() {
return {
active: this.isFilterActive,
matchingIds: Array.from(this.matchingEventIds)
};
}
}
//# sourceMappingURL=EventFilterManager.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,78 @@
/**
* 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, 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[][];
}
export interface IStackedEventLayout {
event: ICalendarEvent;
stackLink: IStackLink;
position: {
top: number;
height: number;
};
}
export interface IColumnLayout {
gridGroups: IGridGroupLayout[];
stackedEvents: IStackedEventLayout[];
}
export declare class EventLayoutCoordinator {
private stackManager;
private config;
private positionUtils;
constructor(stackManager: EventStackManager, config: Configuration, positionUtils: PositionUtils);
/**
* Calculate complete layout for a column of events (recursive approach)
*/
calculateColumnLayout(columnEvents: ICalendarEvent[]): IColumnLayout;
/**
* Calculate stack level for a grid group based on already rendered events
*/
private calculateGridGroupStackLevelFromRendered;
/**
* Calculate stack level for a single stacked event based on already rendered events
*/
private calculateStackLevelFromRendered;
/**
* 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;
/**
* Expand grid candidates to find all events connected by conflict chains
*
* Uses expanding search to find chains (ABC 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;
/**
* 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;
}

View file

@ -0,0 +1,201 @@
/**
* EventLayoutCoordinator - Coordinates event layout calculations
*
* Separates layout logic from rendering concerns.
* Calculates stack levels, groups events, and determines rendering strategy.
*/
export class EventLayoutCoordinator {
constructor(stackManager, config, positionUtils) {
this.stackManager = stackManager;
this.config = config;
this.positionUtils = positionUtils;
}
/**
* Calculate complete layout for a column of events (recursive approach)
*/
calculateColumnLayout(columnEvents) {
if (columnEvents.length === 0) {
return { gridGroups: [], stackedEvents: [] };
}
const gridGroupLayouts = [];
const stackedEventLayouts = [];
const renderedEventsWithLevels = [];
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 = {
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
*/
calculateGridGroupStackLevelFromRendered(gridEvents, renderedEventsWithLevels) {
// 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
*/
calculateStackLevelFromRendered(event, renderedEventsWithLevels) {
// 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
*/
detectConflict(event1, event2, thresholdMinutes) {
// 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 (ABC 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
*/
expandGridCandidates(firstEvent, remaining, thresholdMinutes) {
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
*/
allocateColumns(events) {
if (events.length === 0)
return [];
if (events.length === 1)
return [[events[0]]];
const columns = [];
// 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;
}
}
//# sourceMappingURL=EventLayoutCoordinator.js.map

File diff suppressed because one or more lines are too long

69
wwwroot/js/managers/EventManager.d.ts vendored Normal file
View file

@ -0,0 +1,69 @@
import { IEventBus, ICalendarEvent } from '../types/CalendarTypes';
import { Configuration } from '../configurations/CalendarConfig';
import { DateService } from '../utils/DateService';
import { IEventRepository } from '../repositories/IEventRepository';
/**
* EventManager - Event lifecycle and CRUD operations
* Delegates all data operations to IEventRepository
* No longer maintains in-memory cache - repository is single source of truth
*/
export declare class EventManager {
private eventBus;
private dateService;
private config;
private repository;
constructor(eventBus: IEventBus, dateService: DateService, config: Configuration, repository: IEventRepository);
/**
* Load event data from repository
* No longer caches - delegates to repository
*/
loadData(): Promise<void>;
/**
* Get all events from repository
*/
getEvents(copy?: boolean): Promise<ICalendarEvent[]>;
/**
* Get event by ID from repository
*/
getEventById(id: string): Promise<ICalendarEvent | 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
*/
getEventForNavigation(id: string): Promise<{
event: ICalendarEvent;
eventDate: Date;
} | null>;
/**
* 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
*/
navigateToEvent(eventId: string): Promise<boolean>;
/**
* Get events that overlap with a given time period
*/
getEventsForPeriod(startDate: Date, endDate: Date): Promise<ICalendarEvent[]>;
/**
* Create a new event and add it to the calendar
* Delegates to repository with source='local'
*/
addEvent(event: Omit<ICalendarEvent, 'id'>): Promise<ICalendarEvent>;
/**
* Update an existing event
* Delegates to repository with source='local'
*/
updateEvent(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent | null>;
/**
* Delete an event
* Delegates to repository with source='local'
*/
deleteEvent(id: string): Promise<boolean>;
/**
* Handle remote update from SignalR
* Delegates to repository with source='remote'
*/
handleRemoteUpdate(event: ICalendarEvent): Promise<void>;
}

View file

@ -0,0 +1,164 @@
import { CoreEvents } from '../constants/CoreEvents';
/**
* EventManager - Event lifecycle and CRUD operations
* Delegates all data operations to IEventRepository
* No longer maintains in-memory cache - repository is single source of truth
*/
export class EventManager {
constructor(eventBus, dateService, config, repository) {
this.eventBus = eventBus;
this.dateService = dateService;
this.config = config;
this.repository = repository;
}
/**
* Load event data from repository
* No longer caches - delegates to repository
*/
async loadData() {
try {
// Just ensure repository is ready - no caching
await this.repository.loadEvents();
}
catch (error) {
console.error('Failed to load event data:', error);
throw error;
}
}
/**
* Get all events from repository
*/
async getEvents(copy = false) {
const events = await this.repository.loadEvents();
return copy ? [...events] : events;
}
/**
* Get event by ID from repository
*/
async getEventById(id) {
const events = await this.repository.loadEvents();
return events.find(event => event.id === id);
}
/**
* 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
*/
async getEventForNavigation(id) {
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
*/
async navigateToEvent(eventId) {
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
*/
async getEventsForPeriod(startDate, endDate) {
const events = await this.repository.loadEvents();
// 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
* Delegates to repository with source='local'
*/
async addEvent(event) {
const newEvent = await this.repository.createEvent(event, 'local');
this.eventBus.emit(CoreEvents.EVENT_CREATED, {
event: newEvent
});
return newEvent;
}
/**
* Update an existing event
* Delegates to repository with source='local'
*/
async updateEvent(id, updates) {
try {
const updatedEvent = await this.repository.updateEvent(id, updates, 'local');
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
* Delegates to repository with source='local'
*/
async deleteEvent(id) {
try {
await this.repository.deleteEvent(id, 'local');
this.eventBus.emit(CoreEvents.EVENT_DELETED, {
eventId: id
});
return true;
}
catch (error) {
console.error(`Failed to delete event ${id}:`, error);
return false;
}
}
/**
* Handle remote update from SignalR
* Delegates to repository with source='remote'
*/
async handleRemoteUpdate(event) {
try {
await this.repository.updateEvent(event.id, event, 'remote');
this.eventBus.emit(CoreEvents.REMOTE_UPDATE_RECEIVED, {
event
});
this.eventBus.emit(CoreEvents.EVENT_UPDATED, {
event
});
}
catch (error) {
console.error(`Failed to handle remote update for event ${event.id}:`, error);
}
}
}
//# sourceMappingURL=EventManager.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"EventManager.js","sourceRoot":"","sources":["../../../src/managers/EventManager.ts"],"names":[],"mappings":"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"}

View file

@ -0,0 +1,91 @@
/**
* 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;
next?: string;
stackLevel: number;
}
export interface IEventGroup {
events: ICalendarEvent[];
containerType: 'NONE' | 'GRID' | 'STACKING';
startTime: Date;
}
export declare class EventStackManager {
private static readonly STACK_OFFSET_PX;
private config;
constructor(config: Configuration);
/**
* 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)
*/
groupEventsByStartTime(events: ICalendarEvent[]): IEventGroup[];
/**
* 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.
*/
decideContainerType(group: IEventGroup): 'NONE' | 'GRID' | 'STACKING';
/**
* Check if two events overlap in time
*/
doEventsOverlap(event1: ICalendarEvent, event2: ICalendarEvent): boolean;
/**
* Create optimized stack links (events share levels when possible)
*/
createOptimizedStackLinks(events: ICalendarEvent[]): Map<string, IStackLink>;
/**
* Calculate marginLeft based on stack level
*/
calculateMarginLeft(stackLevel: number): number;
/**
* Calculate zIndex based on stack level
*/
calculateZIndex(stackLevel: number): number;
/**
* Serialize stack link to JSON string
*/
serializeStackLink(stackLink: IStackLink): string;
/**
* Deserialize JSON string to stack link
*/
deserializeStackLink(json: string): IStackLink | null;
/**
* Apply stack link to DOM element
*/
applyStackLinkToElement(element: HTMLElement, stackLink: IStackLink): void;
/**
* Get stack link from DOM element
*/
getStackLinkFromElement(element: HTMLElement): IStackLink | null;
/**
* Apply visual styling to element based on stack level
*/
applyVisualStyling(element: HTMLElement, stackLevel: number): void;
/**
* Clear stack link from element
*/
clearStackLinkFromElement(element: HTMLElement): void;
/**
* Clear visual styling from element
*/
clearVisualStyling(element: HTMLElement): void;
}

View file

@ -0,0 +1,217 @@
/**
* 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
*/
export class EventStackManager {
constructor(config) {
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)
*/
groupEventsByStartTime(events) {
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 = [];
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.
*/
decideContainerType(group) {
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
*/
doEventsOverlap(event1, event2) {
return event1.start < event2.end && event1.end > event2.start;
}
// ============================================
// Stack Level Calculation
// ============================================
/**
* Create optimized stack links (events share levels when possible)
*/
createOptimizedStackLinks(events) {
const stackLinks = new Map();
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
*/
calculateMarginLeft(stackLevel) {
return stackLevel * EventStackManager.STACK_OFFSET_PX;
}
/**
* Calculate zIndex based on stack level
*/
calculateZIndex(stackLevel) {
return 100 + stackLevel;
}
/**
* Serialize stack link to JSON string
*/
serializeStackLink(stackLink) {
return JSON.stringify(stackLink);
}
/**
* Deserialize JSON string to stack link
*/
deserializeStackLink(json) {
try {
return JSON.parse(json);
}
catch (e) {
return null;
}
}
/**
* Apply stack link to DOM element
*/
applyStackLinkToElement(element, stackLink) {
element.dataset.stackLink = this.serializeStackLink(stackLink);
}
/**
* Get stack link from DOM element
*/
getStackLinkFromElement(element) {
const data = element.dataset.stackLink;
if (!data)
return null;
return this.deserializeStackLink(data);
}
/**
* Apply visual styling to element based on stack level
*/
applyVisualStyling(element, stackLevel) {
element.style.marginLeft = `${this.calculateMarginLeft(stackLevel)}px`;
element.style.zIndex = `${this.calculateZIndex(stackLevel)}`;
}
/**
* Clear stack link from element
*/
clearStackLinkFromElement(element) {
delete element.dataset.stackLink;
}
/**
* Clear visual styling from element
*/
clearVisualStyling(element) {
element.style.marginLeft = '';
element.style.zIndex = '';
}
}
EventStackManager.STACK_OFFSET_PX = 15;
//# sourceMappingURL=EventStackManager.js.map

File diff suppressed because one or more lines are too long

30
wwwroot/js/managers/GridManager.d.ts vendored Normal file
View file

@ -0,0 +1,30 @@
/**
* GridManager - Simplified grid manager using centralized GridRenderer
* Delegates DOM rendering to GridRenderer, focuses on coordination
*/
import { GridRenderer } from '../renderers/GridRenderer';
import { DateService } from '../utils/DateService';
import { Configuration } from '../configurations/CalendarConfig';
import { EventManager } from './EventManager';
/**
* Simplified GridManager focused on coordination, delegates rendering to GridRenderer
*/
export declare class GridManager {
private container;
private currentDate;
private currentView;
private gridRenderer;
private dateService;
private config;
private dataSource;
private eventManager;
constructor(gridRenderer: GridRenderer, dateService: DateService, config: Configuration, eventManager: EventManager);
private init;
private findElements;
private subscribeToEvents;
/**
* Main render method - delegates to GridRenderer
* Note: CSS variables are automatically updated by ConfigManager when config changes
*/
render(): Promise<void>;
}

View file

@ -0,0 +1,77 @@
/**
* GridManager - Simplified grid manager using centralized GridRenderer
* Delegates DOM rendering to GridRenderer, focuses on coordination
*/
import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { DateColumnDataSource } from '../datasources/DateColumnDataSource';
/**
* Simplified GridManager focused on coordination, delegates rendering to GridRenderer
*/
export class GridManager {
constructor(gridRenderer, dateService, config, eventManager) {
this.container = null;
this.currentDate = new Date();
this.currentView = 'week';
this.gridRenderer = gridRenderer;
this.dateService = dateService;
this.config = config;
this.eventManager = eventManager;
this.dataSource = new DateColumnDataSource(dateService, config, this.currentDate, this.currentView);
this.init();
}
init() {
this.findElements();
this.subscribeToEvents();
}
findElements() {
this.container = document.querySelector('swp-calendar-container');
}
subscribeToEvents() {
// Listen for view changes
eventBus.on(CoreEvents.VIEW_CHANGED, (e) => {
const detail = e.detail;
this.currentView = detail.currentView;
this.dataSource.setCurrentView(this.currentView);
this.render();
});
// Listen for navigation events from NavigationButtons
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (e) => {
const detail = e.detail;
this.currentDate = detail.newDate;
this.dataSource.setCurrentDate(this.currentDate);
this.render();
});
// Listen for config changes that affect rendering
eventBus.on(CoreEvents.REFRESH_REQUESTED, (e) => {
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
*/
async render() {
if (!this.container) {
return;
}
// Get dates from datasource - single source of truth
const dates = this.dataSource.getColumns();
// Get events for the period from EventManager
const startDate = dates[0];
const endDate = dates[dates.length - 1];
const events = await this.eventManager.getEventsForPeriod(startDate, endDate);
// Delegate to GridRenderer with dates and events
this.gridRenderer.renderGrid(this.container, this.currentDate, this.currentView, dates, events);
// Emit grid rendered event
eventBus.emit(CoreEvents.GRID_RENDERED, {
container: this.container,
currentDate: this.currentDate,
dates: dates
});
}
}
//# sourceMappingURL=GridManager.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"GridManager.js","sourceRoot":"","sources":["../../../src/managers/GridManager.ts"],"names":[],"mappings":"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"}

32
wwwroot/js/managers/HeaderManager.d.ts vendored Normal file
View file

@ -0,0 +1,32 @@
import { Configuration } from '../configurations/CalendarConfig';
import { IHeaderRenderer } from '../renderers/DateHeaderRenderer';
/**
* HeaderManager - Handles all header-related event logic
* Separates event handling from rendering concerns
* Uses dependency injection for renderer strategy
*/
export declare class HeaderManager {
private headerRenderer;
private config;
constructor(headerRenderer: IHeaderRenderer, config: Configuration);
/**
* Setup header drag event listeners - Listen to DragDropManager events
*/
setupHeaderDragListeners(): void;
/**
* Handle drag mouse enter header event
*/
private handleDragMouseEnterHeader;
/**
* Handle drag mouse leave header event
*/
private handleDragMouseLeaveHeader;
/**
* Setup navigation event listener
*/
private setupNavigationListener;
/**
* Update header content for navigation
*/
private updateHeader;
}

View file

@ -0,0 +1,103 @@
import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
/**
* HeaderManager - Handles all header-related event logic
* Separates event handling from rendering concerns
* Uses dependency injection for renderer strategy
*/
export class HeaderManager {
constructor(headerRenderer, config) {
this.headerRenderer = headerRenderer;
this.config = config;
// 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
*/
setupHeaderDragListeners() {
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
*/
handleDragMouseEnterHeader(event) {
const { targetColumn: targetDate, mousePosition, originalElement, draggedClone: cloneElement } = event.detail;
console.log('🎯 HeaderManager: Received drag:mouseenter-header', {
targetDate,
originalElement: !!originalElement,
cloneElement: !!cloneElement
});
}
/**
* Handle drag mouse leave header event
*/
handleDragMouseLeaveHeader(event) {
const { targetDate, mousePosition, originalElement, draggedClone: cloneElement } = event.detail;
console.log('🚪 HeaderManager: Received drag:mouseleave-header', {
targetDate,
originalElement: !!originalElement,
cloneElement: !!cloneElement
});
}
/**
* Setup navigation event listener
*/
setupNavigationListener() {
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, (event) => {
const { currentDate } = event.detail;
this.updateHeader(currentDate);
});
// Also listen for date changes (including initial setup)
eventBus.on(CoreEvents.DATE_CHANGED, (event) => {
const { currentDate } = event.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.detail;
this.updateHeader(currentDate);
});
}
/**
* Update header content for navigation
*/
updateHeader(currentDate) {
console.log('🎯 HeaderManager.updateHeader called', {
currentDate,
rendererType: this.headerRenderer.constructor.name
});
const calendarHeader = document.querySelector('swp-calendar-header');
if (!calendarHeader) {
console.warn('❌ HeaderManager: No calendar header found!');
return;
}
// Clear existing content
calendarHeader.innerHTML = '';
// Render new header content using injected renderer
const context = {
currentWeek: currentDate,
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 = {
headerElements: ColumnDetectionUtils.getHeaderColumns(),
};
eventBus.emit('header:ready', payload);
}
}
//# sourceMappingURL=HeaderManager.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"HeaderManager.js","sourceRoot":"","sources":["../../../src/managers/HeaderManager.ts"],"names":[],"mappings":"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"}

View file

@ -0,0 +1,40 @@
import { IEventBus } from '../types/CalendarTypes';
/**
* NavigationButtonsManager - Manages navigation button UI and state
*
* RESPONSIBILITY:
* ===============
* This manager owns all logic related to the <swp-nav-group> UI element.
* It follows the principle that each functional UI element has its own manager.
*
* RESPONSIBILITIES:
* - Handles button clicks on swp-nav-button elements
* - Validates navigation actions (prev, next, today)
* - Emits NAV_BUTTON_CLICKED events
* - Manages button UI listeners
*
* EVENT FLOW:
* ===========
* User clicks button validateAction() emit event NavigationManager handles navigation
*
* SUBSCRIBERS:
* ============
* - NavigationManager: Performs actual navigation logic (animations, grid updates, week calculations)
*/
export declare class NavigationButtonsManager {
private eventBus;
private buttonListeners;
constructor(eventBus: IEventBus);
/**
* Setup click listeners on all navigation buttons
*/
private setupButtonListeners;
/**
* Handle navigation action
*/
private handleNavigation;
/**
* Validate if string is a valid navigation action
*/
private isValidAction;
}

View file

@ -0,0 +1,63 @@
import { CoreEvents } from '../constants/CoreEvents';
/**
* NavigationButtonsManager - Manages navigation button UI and state
*
* RESPONSIBILITY:
* ===============
* This manager owns all logic related to the <swp-nav-group> UI element.
* It follows the principle that each functional UI element has its own manager.
*
* RESPONSIBILITIES:
* - Handles button clicks on swp-nav-button elements
* - Validates navigation actions (prev, next, today)
* - Emits NAV_BUTTON_CLICKED events
* - Manages button UI listeners
*
* EVENT FLOW:
* ===========
* User clicks button validateAction() emit event NavigationManager handles navigation
*
* SUBSCRIBERS:
* ============
* - NavigationManager: Performs actual navigation logic (animations, grid updates, week calculations)
*/
export class NavigationButtonsManager {
constructor(eventBus) {
this.buttonListeners = new Map();
this.eventBus = eventBus;
this.setupButtonListeners();
}
/**
* Setup click listeners on all navigation buttons
*/
setupButtonListeners() {
const buttons = document.querySelectorAll('swp-nav-button[data-action]');
buttons.forEach(button => {
const clickHandler = (event) => {
event.preventDefault();
const action = button.getAttribute('data-action');
if (action && this.isValidAction(action)) {
this.handleNavigation(action);
}
};
button.addEventListener('click', clickHandler);
this.buttonListeners.set(button, clickHandler);
});
}
/**
* Handle navigation action
*/
handleNavigation(action) {
// Emit navigation button clicked event
this.eventBus.emit(CoreEvents.NAV_BUTTON_CLICKED, {
action: action
});
}
/**
* Validate if string is a valid navigation action
*/
isValidAction(action) {
return ['prev', 'next', 'today'].includes(action);
}
}
//# sourceMappingURL=NavigationButtonsManager.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"NavigationButtonsManager.js","sourceRoot":"","sources":["../../../src/managers/NavigationButtonsManager.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,OAAO,wBAAwB;IAInC,YAAY,QAAmB;QAFvB,oBAAe,GAAgC,IAAI,GAAG,EAAE,CAAC;QAG/D,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,oBAAoB,EAAE,CAAC;IAC9B,CAAC;IAED;;OAEG;IACK,oBAAoB;QAC1B,MAAM,OAAO,GAAG,QAAQ,CAAC,gBAAgB,CAAC,6BAA6B,CAAC,CAAC;QAEzE,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE;YACvB,MAAM,YAAY,GAAG,CAAC,KAAY,EAAE,EAAE;gBACpC,KAAK,CAAC,cAAc,EAAE,CAAC;gBACvB,MAAM,MAAM,GAAG,MAAM,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;gBAClD,IAAI,MAAM,IAAI,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,EAAE,CAAC;oBACzC,IAAI,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC;gBAChC,CAAC;YACH,CAAC,CAAC;YAEF,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,YAAY,CAAC,CAAC;YAC/C,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;QACjD,CAAC,CAAC,CAAC;IACL,CAAC;IAED;;OAEG;IACK,gBAAgB,CAAC,MAAc;QACrC,uCAAuC;QACvC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,kBAAkB,EAAE;YAChD,MAAM,EAAE,MAAM;SACf,CAAC,CAAC;IACL,CAAC;IAED;;OAEG;IACK,aAAa,CAAC,MAAc;QAClC,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACpD,CAAC;CACF"}

View file

@ -0,0 +1,32 @@
import { IEventBus } from '../types/CalendarTypes';
import { EventRenderingService } from '../renderers/EventRendererManager';
import { DateService } from '../utils/DateService';
import { WeekInfoRenderer } from '../renderers/WeekInfoRenderer';
import { GridRenderer } from '../renderers/GridRenderer';
export declare class NavigationManager {
private eventBus;
private weekInfoRenderer;
private gridRenderer;
private dateService;
private currentWeek;
private targetWeek;
private animationQueue;
constructor(eventBus: IEventBus, eventRenderer: EventRenderingService, gridRenderer: GridRenderer, dateService: DateService, weekInfoRenderer: WeekInfoRenderer);
private init;
/**
* 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;
private setupEventListeners;
/**
* Navigate to specific event date and emit scroll event after navigation
*/
private navigateToEventDate;
private navigateToDate;
/**
* Animation transition using pre-rendered containers when available
*/
private animateTransition;
}

View file

@ -0,0 +1,188 @@
import { CoreEvents } from '../constants/CoreEvents';
export class NavigationManager {
constructor(eventBus, eventRenderer, gridRenderer, dateService, weekInfoRenderer) {
this.animationQueue = 0;
this.eventBus = eventBus;
this.dateService = dateService;
this.weekInfoRenderer = weekInfoRenderer;
this.gridRenderer = gridRenderer;
this.currentWeek = this.getISOWeekStart(new Date());
this.targetWeek = new Date(this.currentWeek);
this.init();
}
init() {
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
*/
getISOWeekStart(date) {
const weekBounds = this.dateService.getWeekBounds(date);
return this.dateService.startOfDay(weekBounds.start);
}
setupEventListeners() {
// Listen for filter changes and apply to pre-rendered grids
this.eventBus.on(CoreEvents.FILTER_CHANGED, (e) => {
const detail = e.detail;
this.weekInfoRenderer.applyFilterToPreRenderedGrids(detail);
});
// Listen for navigation button clicks from NavigationButtons
this.eventBus.on(CoreEvents.NAV_BUTTON_CLICKED, (event) => {
const { direction, newDate } = event.detail;
// Navigate to the new date with animation
this.navigateToDate(newDate, direction);
});
// Listen for external navigation requests
this.eventBus.on(CoreEvents.DATE_CHANGED, (event) => {
const customEvent = event;
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) => {
const customEvent = event;
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
*/
navigateToEventDate(eventDate, eventStartTime) {
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();
}
}
navigateToDate(date, direction) {
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;
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
*/
animateTransition(direction, targetWeek) {
const container = document.querySelector('swp-calendar-container');
const currentGrid = document.querySelector('swp-calendar-container swp-grid-container:not([data-prerendered])');
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;
console.group('🔧 NavigationManager.refactored');
console.log('Calling GridRenderer instead of NavigationRenderer');
console.log('Target week:', targetWeek);
// Always create a fresh container for consistent behavior
newGrid = this.gridRenderer.createNavigationGrid(container, 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
});
});
}
}
//# sourceMappingURL=NavigationManager.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,42 @@
import { Configuration } from '../configurations/CalendarConfig';
import { PositionUtils } from '../utils/PositionUtils';
export declare class ResizeHandleManager {
private config;
private positionUtils;
private isResizing;
private targetEl;
private startY;
private startDurationMin;
private snapMin;
private minDurationMin;
private animationId;
private currentHeight;
private targetHeight;
private pointerCaptured;
private prevZ?;
private readonly ANIMATION_SPEED;
private readonly Z_INDEX_RESIZING;
private readonly EVENT_REFRESH_THRESHOLD;
constructor(config: Configuration, positionUtils: PositionUtils);
initialize(): void;
destroy(): void;
private removeEventListeners;
private createResizeHandle;
private attachGlobalListeners;
private onMouseOver;
private onPointerDown;
private startResizing;
private setZIndexForResizing;
private capturePointer;
private onPointerMove;
private updateResizeHeight;
private animate;
private finalizeAnimation;
private onPointerUp;
private cleanupAnimation;
private snapToGrid;
private emitResizeEndEvent;
private cleanupResizing;
private restoreZIndex;
private releasePointer;
}

View file

@ -0,0 +1,194 @@
import { eventBus } from '../core/EventBus';
export class ResizeHandleManager {
constructor(config, positionUtils) {
this.config = config;
this.positionUtils = positionUtils;
this.isResizing = false;
this.targetEl = null;
this.startY = 0;
this.startDurationMin = 0;
this.animationId = null;
this.currentHeight = 0;
this.targetHeight = 0;
this.pointerCaptured = false;
// Constants for better maintainability
this.ANIMATION_SPEED = 0.35;
this.Z_INDEX_RESIZING = '1000';
this.EVENT_REFRESH_THRESHOLD = 0.5;
this.onMouseOver = (e) => {
const target = e.target;
const eventElement = target.closest('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);
}
}
};
this.onPointerDown = (e) => {
const handle = e.target.closest('swp-resize-handle');
if (!handle)
return;
const element = handle.parentElement;
this.startResizing(element, e);
};
this.onPointerMove = (e) => {
if (!this.isResizing || !this.targetEl)
return;
this.updateResizeHeight(e.clientY);
};
this.animate = () => {
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();
}
};
this.onPointerUp = (e) => {
if (!this.isResizing || !this.targetEl)
return;
this.cleanupAnimation();
this.snapToGrid();
this.emitResizeEndEvent();
this.cleanupResizing(e);
};
const grid = this.config.gridSettings;
this.snapMin = grid.snapInterval;
this.minDurationMin = this.snapMin;
}
initialize() {
this.attachGlobalListeners();
}
destroy() {
this.removeEventListeners();
}
removeEventListeners() {
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);
}
createResizeHandle() {
const handle = document.createElement('swp-resize-handle');
handle.setAttribute('aria-label', 'Resize event');
handle.setAttribute('role', 'separator');
return handle;
}
attachGlobalListeners() {
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);
}
startResizing(element, event) {
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();
}
setZIndexForResizing(element) {
const container = element.closest('swp-event-group') ?? element;
this.prevZ = container.style.zIndex;
container.style.zIndex = this.Z_INDEX_RESIZING;
}
capturePointer(event) {
try {
event.target.setPointerCapture?.(event.pointerId);
this.pointerCaptured = true;
}
catch (error) {
console.warn('Pointer capture failed:', error);
}
}
updateResizeHeight(currentY) {
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();
}
}
finalizeAnimation() {
if (!this.targetEl)
return;
this.currentHeight = this.targetHeight;
this.targetEl.updateHeight?.(this.currentHeight);
this.animationId = null;
}
cleanupAnimation() {
if (this.animationId != null) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
snapToGrid() {
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);
}
emitResizeEndEvent() {
if (!this.targetEl)
return;
const eventId = this.targetEl.dataset.eventId || '';
const resizeEndPayload = {
eventId,
element: this.targetEl,
finalHeight: this.targetEl.offsetHeight
};
eventBus.emit('resize:end', resizeEndPayload);
}
cleanupResizing(event) {
this.restoreZIndex();
this.releasePointer(event);
this.isResizing = false;
this.targetEl = null;
document.documentElement.classList.remove('swp--resizing');
}
restoreZIndex() {
if (!this.targetEl || this.prevZ === undefined)
return;
const container = this.targetEl.closest('swp-event-group') ?? this.targetEl;
container.style.zIndex = this.prevZ;
this.prevZ = undefined;
}
releasePointer(event) {
if (!this.pointerCaptured)
return;
try {
event.target.releasePointerCapture?.(event.pointerId);
this.pointerCaptured = false;
}
catch (error) {
console.warn('Pointer release failed:', error);
}
}
}
//# sourceMappingURL=ResizeHandleManager.js.map

File diff suppressed because one or more lines are too long

64
wwwroot/js/managers/ScrollManager.d.ts vendored Normal file
View file

@ -0,0 +1,64 @@
import { PositionUtils } from '../utils/PositionUtils';
/**
* Manages scrolling functionality for the calendar using native scrollbars
*/
export declare class ScrollManager {
private scrollableContent;
private calendarContainer;
private timeAxis;
private calendarHeader;
private resizeObserver;
private positionUtils;
constructor(positionUtils: PositionUtils);
private init;
/**
* Public method to initialize scroll after grid is rendered
*/
initialize(): void;
private subscribeToEvents;
/**
* Setup scrolling functionality after grid is rendered
*/
private setupScrolling;
/**
* Find DOM elements needed for scrolling
*/
private findElements;
/**
* Scroll to specific position
*/
scrollTo(scrollTop: number): void;
/**
* Scroll to specific hour using PositionUtils
*/
scrollToHour(hour: number): void;
/**
* Scroll to specific event time
* @param eventStartTime ISO string of event start time
*/
scrollToEventTime(eventStartTime: string): void;
/**
* Setup ResizeObserver to monitor container size changes
*/
private setupResizeObserver;
/**
* Calculate and update scrollable content height dynamically
*/
private updateScrollableHeight;
/**
* Setup scroll synchronization between scrollable content and time axis
*/
private setupScrollSynchronization;
/**
* Synchronize time axis position with scrollable content
*/
private syncTimeAxisPosition;
/**
* Setup horizontal scroll synchronization between scrollable content and calendar header
*/
private setupHorizontalScrollSynchronization;
/**
* Synchronize calendar header position with scrollable content horizontal scroll
*/
private syncCalendarHeaderPosition;
}

View file

@ -0,0 +1,217 @@
// Custom scroll management for calendar week container
import { eventBus } from '../core/EventBus';
import { CoreEvents } from '../constants/CoreEvents';
/**
* Manages scrolling functionality for the calendar using native scrollbars
*/
export class ScrollManager {
constructor(positionUtils) {
this.scrollableContent = null;
this.calendarContainer = null;
this.timeAxis = null;
this.calendarHeader = null;
this.resizeObserver = null;
this.positionUtils = positionUtils;
this.init();
}
init() {
this.subscribeToEvents();
}
/**
* Public method to initialize scroll after grid is rendered
*/
initialize() {
this.setupScrolling();
}
subscribeToEvents() {
// 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) => {
const customEvent = event;
const { eventStartTime } = customEvent.detail;
if (eventStartTime) {
this.scrollToEventTime(eventStartTime);
}
});
}
/**
* Setup scrolling functionality after grid is rendered
*/
setupScrolling() {
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
*/
findElements() {
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) {
if (!this.scrollableContent)
return;
this.scrollableContent.scrollTop = scrollTop;
}
/**
* Scroll to specific hour using PositionUtils
*/
scrollToHour(hour) {
// 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) {
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
*/
setupResizeObserver() {
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
*/
updateScrollableHeight() {
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
*/
setupScrollSynchronization() {
if (!this.scrollableContent || !this.timeAxis)
return;
// Throttle scroll events for better performance
let scrollTimeout = null;
this.scrollableContent.addEventListener('scroll', () => {
if (scrollTimeout) {
cancelAnimationFrame(scrollTimeout);
}
scrollTimeout = requestAnimationFrame(() => {
this.syncTimeAxisPosition();
});
});
}
/**
* Synchronize time axis position with scrollable content
*/
syncTimeAxisPosition() {
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.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
*/
setupHorizontalScrollSynchronization() {
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
*/
syncCalendarHeaderPosition() {
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
}
}
}
//# sourceMappingURL=ScrollManager.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,80 @@
/**
* SimpleEventOverlapManager - Clean, focused overlap management
* Eliminates complex state tracking in favor of direct DOM manipulation
*/
import { CalendarEvent } from '../types/CalendarTypes';
export declare enum OverlapType {
NONE = "none",
COLUMN_SHARING = "column_sharing",
STACKING = "stacking"
}
export interface OverlapGroup {
type: OverlapType;
events: CalendarEvent[];
position: {
top: number;
height: number;
};
}
export interface StackLink {
prev?: string;
next?: string;
stackLevel: number;
}
export declare class SimpleEventOverlapManager {
private static readonly STACKING_WIDTH_REDUCTION_PX;
/**
* Detect overlap type between two DOM elements - pixel-based logic
*/
resolveOverlapType(element1: HTMLElement, element2: HTMLElement): OverlapType;
/**
* Group overlapping elements - pixel-based algorithm
*/
groupOverlappingElements(elements: HTMLElement[]): HTMLElement[][];
/**
* Create flexbox container for column sharing - clean and simple
*/
createEventGroup(events: CalendarEvent[], position: {
top: number;
height: number;
}): HTMLElement;
/**
* Add event to flexbox group - simple relative positioning
*/
addToEventGroup(container: HTMLElement, eventElement: HTMLElement): void;
/**
* Create stacked event with data-attribute tracking
*/
createStackedEvent(eventElement: HTMLElement, underlyingElement: HTMLElement, stackLevel: number): void;
/**
* Remove stacked styling with proper stack re-linking
*/
removeStackedStyling(eventElement: HTMLElement): void;
/**
* Update stack levels for all events following a given event ID
*/
private updateSubsequentStackLevels;
/**
* Check if element is stacked - check both style and data-stack-link
*/
isStackedEvent(element: HTMLElement): boolean;
/**
* Remove event from group with proper cleanup
*/
removeFromEventGroup(container: HTMLElement, eventId: string): boolean;
/**
* Restack events in container - respects separate stack chains
*/
restackEventsInContainer(container: HTMLElement): void;
/**
* Utility methods - simple DOM traversal
*/
getEventGroup(eventElement: HTMLElement): HTMLElement | null;
isInEventGroup(element: HTMLElement): boolean;
/**
* Helper methods for data-attribute based stack tracking
*/
getStackLink(element: HTMLElement): StackLink | null;
private setStackLink;
private findElementById;
}

View file

@ -0,0 +1,399 @@
/**
* SimpleEventOverlapManager - Clean, focused overlap management
* Eliminates complex state tracking in favor of direct DOM manipulation
*/
import { calendarConfig } from '../core/CalendarConfig';
export var OverlapType;
(function (OverlapType) {
OverlapType["NONE"] = "none";
OverlapType["COLUMN_SHARING"] = "column_sharing";
OverlapType["STACKING"] = "stacking";
})(OverlapType || (OverlapType = {}));
export class SimpleEventOverlapManager {
/**
* Detect overlap type between two DOM elements - pixel-based logic
*/
resolveOverlapType(element1, element2) {
const top1 = parseInt(element1.style.top) || 0;
const height1 = parseInt(element1.style.height) || 0;
const bottom1 = top1 + height1;
const top2 = parseInt(element2.style.top) || 0;
const height2 = parseInt(element2.style.height) || 0;
const bottom2 = top2 + height2;
// Check if events overlap in pixel space
const tolerance = 2;
if (bottom1 <= (top2 + tolerance) || bottom2 <= (top1 + tolerance)) {
return OverlapType.NONE;
}
// Events overlap - check start position difference for overlap type
const startDifference = Math.abs(top1 - top2);
// Over 40px start difference = stacking
if (startDifference > 40) {
return OverlapType.STACKING;
}
// Within 40px start difference = column sharing
return OverlapType.COLUMN_SHARING;
}
/**
* Group overlapping elements - pixel-based algorithm
*/
groupOverlappingElements(elements) {
const groups = [];
const processed = new Set();
for (const element of elements) {
if (processed.has(element))
continue;
// Find all elements that overlap with this one
const overlapping = elements.filter(other => {
if (processed.has(other))
return false;
return other === element || this.resolveOverlapType(element, other) !== OverlapType.NONE;
});
// Mark all as processed
overlapping.forEach(e => processed.add(e));
groups.push(overlapping);
}
return groups;
}
/**
* Create flexbox container for column sharing - clean and simple
*/
createEventGroup(events, position) {
const container = document.createElement('swp-event-group');
return container;
}
/**
* Add event to flexbox group - simple relative positioning
*/
addToEventGroup(container, eventElement) {
// Set duration-based height
const duration = eventElement.dataset.duration;
if (duration) {
const durationMinutes = parseInt(duration);
const gridSettings = calendarConfig.getGridSettings();
const height = (durationMinutes / 60) * gridSettings.hourHeight;
eventElement.style.height = `${height - 3}px`;
}
// Flexbox styling
eventElement.style.position = 'relative';
eventElement.style.flex = '1';
eventElement.style.minWidth = '50px';
container.appendChild(eventElement);
}
/**
* Create stacked event with data-attribute tracking
*/
createStackedEvent(eventElement, underlyingElement, stackLevel) {
const marginLeft = stackLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
// Apply visual styling
eventElement.style.marginLeft = `${marginLeft}px`;
eventElement.style.left = '2px';
eventElement.style.right = '2px';
eventElement.style.zIndex = `${100 + stackLevel}`;
// Set up stack linking via data attributes
const eventId = eventElement.dataset.eventId;
const underlyingId = underlyingElement.dataset.eventId;
if (!eventId || !underlyingId) {
console.warn('Missing event IDs for stack linking:', eventId, underlyingId);
return;
}
// Find the last event in the stack chain
let lastElement = underlyingElement;
let lastLink = this.getStackLink(lastElement);
// If underlying doesn't have stack link yet, create it
if (!lastLink) {
this.setStackLink(lastElement, { stackLevel: 0 });
lastLink = { stackLevel: 0 };
}
// Traverse to find the end of the chain
while (lastLink?.next) {
const nextElement = this.findElementById(lastLink.next);
if (!nextElement)
break;
lastElement = nextElement;
lastLink = this.getStackLink(lastElement);
}
// Link the new event to the end of the chain
const lastElementId = lastElement.dataset.eventId;
this.setStackLink(lastElement, {
...lastLink,
next: eventId
});
this.setStackLink(eventElement, {
prev: lastElementId,
stackLevel: stackLevel
});
}
/**
* Remove stacked styling with proper stack re-linking
*/
removeStackedStyling(eventElement) {
// Clear visual styling
eventElement.style.marginLeft = '';
eventElement.style.zIndex = '';
eventElement.style.left = '2px';
eventElement.style.right = '2px';
// Handle stack chain re-linking
const link = this.getStackLink(eventElement);
if (link) {
// Re-link prev and next events
if (link.prev && link.next) {
// Middle element - link prev to next
const prevElement = this.findElementById(link.prev);
const nextElement = this.findElementById(link.next);
if (prevElement && nextElement) {
const prevLink = this.getStackLink(prevElement);
const nextLink = this.getStackLink(nextElement);
// CRITICAL: Check if prev and next actually overlap without the middle element
const actuallyOverlap = this.resolveOverlapType(prevElement, nextElement);
if (!actuallyOverlap) {
// CHAIN BREAKING: prev and next don't overlap - break the chain
console.log('Breaking stack chain - events do not overlap directly');
// Prev element: remove next link (becomes end of its own chain)
this.setStackLink(prevElement, {
...prevLink,
next: undefined
});
// Next element: becomes standalone (remove all stack links and styling)
this.setStackLink(nextElement, null);
nextElement.style.marginLeft = '';
nextElement.style.zIndex = '';
// If next element had subsequent events, they also become standalone
if (nextLink?.next) {
let subsequentId = nextLink.next;
while (subsequentId) {
const subsequentElement = this.findElementById(subsequentId);
if (!subsequentElement)
break;
const subsequentLink = this.getStackLink(subsequentElement);
this.setStackLink(subsequentElement, null);
subsequentElement.style.marginLeft = '';
subsequentElement.style.zIndex = '';
subsequentId = subsequentLink?.next;
}
}
}
else {
// NORMAL STACKING: they overlap, maintain the chain
this.setStackLink(prevElement, {
...prevLink,
next: link.next
});
const correctStackLevel = (prevLink?.stackLevel ?? 0) + 1;
this.setStackLink(nextElement, {
...nextLink,
prev: link.prev,
stackLevel: correctStackLevel
});
// Update visual styling to match new stackLevel
const marginLeft = correctStackLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
nextElement.style.marginLeft = `${marginLeft}px`;
nextElement.style.zIndex = `${100 + correctStackLevel}`;
}
}
}
else if (link.prev) {
// Last element - remove next link from prev
const prevElement = this.findElementById(link.prev);
if (prevElement) {
const prevLink = this.getStackLink(prevElement);
this.setStackLink(prevElement, {
...prevLink,
next: undefined
});
}
}
else if (link.next) {
// First element - remove prev link from next
const nextElement = this.findElementById(link.next);
if (nextElement) {
const nextLink = this.getStackLink(nextElement);
this.setStackLink(nextElement, {
...nextLink,
prev: undefined,
stackLevel: 0 // Next becomes the base event
});
}
}
// Only update subsequent stack levels if we didn't break the chain
if (link.prev && link.next) {
const nextElement = this.findElementById(link.next);
const nextLink = nextElement ? this.getStackLink(nextElement) : null;
// If next element still has a stack link, the chain wasn't broken
if (nextLink && nextLink.next) {
this.updateSubsequentStackLevels(nextLink.next, -1);
}
// If nextLink is null, chain was broken - no subsequent updates needed
}
else {
// First or last removal - update all subsequent
this.updateSubsequentStackLevels(link.next, -1);
}
// Clear this element's stack link
this.setStackLink(eventElement, null);
}
}
/**
* Update stack levels for all events following a given event ID
*/
updateSubsequentStackLevels(startEventId, levelDelta) {
let currentId = startEventId;
while (currentId) {
const currentElement = this.findElementById(currentId);
if (!currentElement)
break;
const currentLink = this.getStackLink(currentElement);
if (!currentLink)
break;
// Update stack level
const newLevel = Math.max(0, currentLink.stackLevel + levelDelta);
this.setStackLink(currentElement, {
...currentLink,
stackLevel: newLevel
});
// Update visual styling
const marginLeft = newLevel * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
currentElement.style.marginLeft = `${marginLeft}px`;
currentElement.style.zIndex = `${100 + newLevel}`;
currentId = currentLink.next;
}
}
/**
* Check if element is stacked - check both style and data-stack-link
*/
isStackedEvent(element) {
const marginLeft = element.style.marginLeft;
const hasMarginLeft = marginLeft !== '' && marginLeft !== '0px';
const hasStackLink = this.getStackLink(element) !== null;
return hasMarginLeft || hasStackLink;
}
/**
* Remove event from group with proper cleanup
*/
removeFromEventGroup(container, eventId) {
const eventElement = container.querySelector(`swp-event[data-event-id="${eventId}"]`);
if (!eventElement)
return false;
// Simply remove the element - no position calculation needed since it's being removed
eventElement.remove();
// Handle remaining events
const remainingEvents = container.querySelectorAll('swp-event');
const remainingCount = remainingEvents.length;
if (remainingCount === 0) {
container.remove();
return true;
}
if (remainingCount === 1) {
const remainingEvent = remainingEvents[0];
// Convert last event back to absolute positioning - use current pixel position
const currentTop = parseInt(remainingEvent.style.top) || 0;
remainingEvent.style.position = 'absolute';
remainingEvent.style.top = `${currentTop}px`;
remainingEvent.style.left = '2px';
remainingEvent.style.right = '2px';
remainingEvent.style.flex = '';
remainingEvent.style.minWidth = '';
container.parentElement?.insertBefore(remainingEvent, container);
container.remove();
return true;
}
return false;
}
/**
* Restack events in container - respects separate stack chains
*/
restackEventsInContainer(container) {
const stackedEvents = Array.from(container.querySelectorAll('swp-event'))
.filter(el => this.isStackedEvent(el));
if (stackedEvents.length === 0)
return;
// Group events by their stack chains
const processedEventIds = new Set();
const stackChains = [];
for (const element of stackedEvents) {
const eventId = element.dataset.eventId;
if (!eventId || processedEventIds.has(eventId))
continue;
// Find the root of this stack chain (stackLevel 0 or no prev link)
let rootElement = element;
let rootLink = this.getStackLink(rootElement);
while (rootLink?.prev) {
const prevElement = this.findElementById(rootLink.prev);
if (!prevElement)
break;
rootElement = prevElement;
rootLink = this.getStackLink(rootElement);
}
// Collect all elements in this chain
const chain = [];
let currentElement = rootElement;
while (currentElement) {
chain.push(currentElement);
processedEventIds.add(currentElement.dataset.eventId);
const currentLink = this.getStackLink(currentElement);
if (!currentLink?.next)
break;
const nextElement = this.findElementById(currentLink.next);
if (!nextElement)
break;
currentElement = nextElement;
}
if (chain.length > 1) { // Only add chains with multiple events
stackChains.push(chain);
}
}
// Re-stack each chain separately
stackChains.forEach(chain => {
chain.forEach((element, index) => {
const marginLeft = index * SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX;
element.style.marginLeft = `${marginLeft}px`;
element.style.zIndex = `${100 + index}`;
// Update the data-stack-link with correct stackLevel
const link = this.getStackLink(element);
if (link) {
this.setStackLink(element, {
...link,
stackLevel: index
});
}
});
});
}
/**
* Utility methods - simple DOM traversal
*/
getEventGroup(eventElement) {
return eventElement.closest('swp-event-group');
}
isInEventGroup(element) {
return this.getEventGroup(element) !== null;
}
/**
* Helper methods for data-attribute based stack tracking
*/
getStackLink(element) {
const linkData = element.dataset.stackLink;
if (!linkData)
return null;
try {
return JSON.parse(linkData);
}
catch (e) {
console.warn('Failed to parse stack link data:', linkData, e);
return null;
}
}
setStackLink(element, link) {
if (link === null) {
delete element.dataset.stackLink;
}
else {
element.dataset.stackLink = JSON.stringify(link);
}
}
findElementById(eventId) {
return document.querySelector(`swp-event[data-event-id="${eventId}"]`);
}
}
SimpleEventOverlapManager.STACKING_WIDTH_REDUCTION_PX = 15;
//# sourceMappingURL=SimpleEventOverlapManager.js.map

File diff suppressed because one or more lines are too long

23
wwwroot/js/managers/ViewManager.d.ts vendored Normal file
View file

@ -0,0 +1,23 @@
import { IEventBus } from '../types/CalendarTypes';
import { Configuration } from '../configurations/CalendarConfig';
export declare class ViewManager {
private eventBus;
private config;
private currentView;
private buttonListeners;
constructor(eventBus: IEventBus, config: Configuration);
private setupEventListeners;
private setupEventBusListeners;
private setupButtonHandlers;
private setupButtonGroup;
private getViewButtons;
private getWorkweekButtons;
private initializeView;
private changeView;
private changeWorkweek;
private updateAllButtons;
private updateButtonGroup;
private emitViewRendered;
private refreshCurrentView;
private isValidView;
}

View file

@ -0,0 +1,106 @@
import { ConfigManager } from '../configurations/ConfigManager';
import { CoreEvents } from '../constants/CoreEvents';
export class ViewManager {
constructor(eventBus, config) {
this.currentView = 'week';
this.buttonListeners = new Map();
this.eventBus = eventBus;
this.config = config;
this.setupEventListeners();
}
setupEventListeners() {
this.setupEventBusListeners();
this.setupButtonHandlers();
}
setupEventBusListeners() {
this.eventBus.on(CoreEvents.INITIALIZED, () => {
this.initializeView();
});
this.eventBus.on(CoreEvents.DATE_CHANGED, () => {
this.refreshCurrentView();
});
}
setupButtonHandlers() {
this.setupButtonGroup('swp-view-button[data-view]', 'data-view', (value) => {
if (this.isValidView(value)) {
this.changeView(value);
}
});
this.setupButtonGroup('swp-preset-button[data-workweek]', 'data-workweek', (value) => {
this.changeWorkweek(value);
});
}
setupButtonGroup(selector, attribute, handler) {
const buttons = document.querySelectorAll(selector);
buttons.forEach(button => {
const clickHandler = (event) => {
event.preventDefault();
const value = button.getAttribute(attribute);
if (value) {
handler(value);
}
};
button.addEventListener('click', clickHandler);
this.buttonListeners.set(button, clickHandler);
});
}
getViewButtons() {
return document.querySelectorAll('swp-view-button[data-view]');
}
getWorkweekButtons() {
return document.querySelectorAll('swp-preset-button[data-workweek]');
}
initializeView() {
this.updateAllButtons();
this.emitViewRendered();
}
changeView(newView) {
if (newView === this.currentView)
return;
const previousView = this.currentView;
this.currentView = newView;
this.updateAllButtons();
this.eventBus.emit(CoreEvents.VIEW_CHANGED, {
previousView,
currentView: newView
});
}
changeWorkweek(workweekId) {
this.config.setWorkWeek(workweekId);
// Update all CSS properties to match new configuration
ConfigManager.updateCSSProperties(this.config);
this.updateAllButtons();
const settings = this.config.getWorkWeekSettings();
this.eventBus.emit(CoreEvents.WORKWEEK_CHANGED, {
workWeekId: workweekId,
settings: settings
});
}
updateAllButtons() {
this.updateButtonGroup(this.getViewButtons(), 'data-view', this.currentView);
this.updateButtonGroup(this.getWorkweekButtons(), 'data-workweek', this.config.currentWorkWeek);
}
updateButtonGroup(buttons, attribute, activeValue) {
buttons.forEach(button => {
const buttonValue = button.getAttribute(attribute);
if (buttonValue === activeValue) {
button.setAttribute('data-active', 'true');
}
else {
button.removeAttribute('data-active');
}
});
}
emitViewRendered() {
this.eventBus.emit(CoreEvents.VIEW_RENDERED, {
view: this.currentView
});
}
refreshCurrentView() {
this.emitViewRendered();
}
isValidView(view) {
return ['day', 'week', 'month'].includes(view);
}
}
//# sourceMappingURL=ViewManager.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"ViewManager.js","sourceRoot":"","sources":["../../../src/managers/ViewManager.ts"],"names":[],"mappings":"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"}

View file

@ -0,0 +1,70 @@
import { IEventBus } from '../types/CalendarTypes';
import { Configuration } from '../configurations/CalendarConfig';
/**
* ViewSelectorManager - Manages view selector UI and state
*
* RESPONSIBILITY:
* ===============
* This manager owns all logic related to the <swp-view-selector> UI element.
* It follows the principle that each functional UI element has its own manager.
*
* RESPONSIBILITIES:
* - Handles button clicks on swp-view-button elements
* - Manages current view state (day/week/month)
* - Validates view values
* - Emits VIEW_CHANGED and VIEW_RENDERED events
* - Updates button UI states (data-active attributes)
*
* EVENT FLOW:
* ===========
* User clicks button changeView() validate update state emit event update UI
*
* IMPLEMENTATION STATUS:
* ======================
* - Week view: FULLY IMPLEMENTED
* - Day view: NOT IMPLEMENTED (button exists but no rendering)
* - Month view: NOT IMPLEMENTED (button exists but no rendering)
*
* SUBSCRIBERS:
* ============
* - GridRenderer: Uses view parameter (currently only supports 'week')
* - Future: DayRenderer, MonthRenderer when implemented
*/
export declare class ViewSelectorManager {
private eventBus;
private config;
private buttonListeners;
constructor(eventBus: IEventBus, config: Configuration);
/**
* Setup click listeners on all view selector buttons
*/
private setupButtonListeners;
/**
* Setup event bus listeners
*/
private setupEventListeners;
/**
* Change the active view
*/
private changeView;
/**
* Update button states (data-active attributes)
*/
private updateButtonStates;
/**
* Initialize view on INITIALIZED event
*/
private initializeView;
/**
* Emit VIEW_RENDERED event
*/
private emitViewRendered;
/**
* Refresh current view on DATE_CHANGED event
*/
private refreshCurrentView;
/**
* Validate if string is a valid CalendarView type
*/
private isValidView;
}

View file

@ -0,0 +1,130 @@
import { CoreEvents } from '../constants/CoreEvents';
/**
* ViewSelectorManager - Manages view selector UI and state
*
* RESPONSIBILITY:
* ===============
* This manager owns all logic related to the <swp-view-selector> UI element.
* It follows the principle that each functional UI element has its own manager.
*
* RESPONSIBILITIES:
* - Handles button clicks on swp-view-button elements
* - Manages current view state (day/week/month)
* - Validates view values
* - Emits VIEW_CHANGED and VIEW_RENDERED events
* - Updates button UI states (data-active attributes)
*
* EVENT FLOW:
* ===========
* User clicks button changeView() validate update state emit event update UI
*
* IMPLEMENTATION STATUS:
* ======================
* - Week view: FULLY IMPLEMENTED
* - Day view: NOT IMPLEMENTED (button exists but no rendering)
* - Month view: NOT IMPLEMENTED (button exists but no rendering)
*
* SUBSCRIBERS:
* ============
* - GridRenderer: Uses view parameter (currently only supports 'week')
* - Future: DayRenderer, MonthRenderer when implemented
*/
export class ViewSelectorManager {
constructor(eventBus, config) {
this.buttonListeners = new Map();
this.eventBus = eventBus;
this.config = config;
this.setupButtonListeners();
this.setupEventListeners();
}
/**
* Setup click listeners on all view selector buttons
*/
setupButtonListeners() {
const buttons = document.querySelectorAll('swp-view-button[data-view]');
buttons.forEach(button => {
const clickHandler = (event) => {
event.preventDefault();
const view = button.getAttribute('data-view');
if (view && this.isValidView(view)) {
this.changeView(view);
}
};
button.addEventListener('click', clickHandler);
this.buttonListeners.set(button, clickHandler);
});
// Initialize button states
this.updateButtonStates();
}
/**
* Setup event bus listeners
*/
setupEventListeners() {
this.eventBus.on(CoreEvents.INITIALIZED, () => {
this.initializeView();
});
this.eventBus.on(CoreEvents.DATE_CHANGED, () => {
this.refreshCurrentView();
});
}
/**
* Change the active view
*/
changeView(newView) {
if (newView === this.config.currentView) {
return; // No change
}
const previousView = this.config.currentView;
this.config.currentView = newView;
// Update button UI states
this.updateButtonStates();
// Emit event for subscribers
this.eventBus.emit(CoreEvents.VIEW_CHANGED, {
previousView,
currentView: newView
});
}
/**
* Update button states (data-active attributes)
*/
updateButtonStates() {
const buttons = document.querySelectorAll('swp-view-button[data-view]');
buttons.forEach(button => {
const buttonView = button.getAttribute('data-view');
if (buttonView === this.config.currentView) {
button.setAttribute('data-active', 'true');
}
else {
button.removeAttribute('data-active');
}
});
}
/**
* Initialize view on INITIALIZED event
*/
initializeView() {
this.updateButtonStates();
this.emitViewRendered();
}
/**
* Emit VIEW_RENDERED event
*/
emitViewRendered() {
this.eventBus.emit(CoreEvents.VIEW_RENDERED, {
view: this.config.currentView
});
}
/**
* Refresh current view on DATE_CHANGED event
*/
refreshCurrentView() {
this.emitViewRendered();
}
/**
* Validate if string is a valid CalendarView type
*/
isValidView(view) {
return ['day', 'week', 'month'].includes(view);
}
}
//# sourceMappingURL=ViewSelectorManager.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"ViewSelectorManager.js","sourceRoot":"","sources":["../../../src/managers/ViewSelectorManager.ts"],"names":[],"mappings":"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"}

View file

@ -0,0 +1,71 @@
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;
end: number;
}
/**
* 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';
};
}
/**
* Manages work hours scheduling with weekly defaults and date-specific overrides
*/
export declare class WorkHoursManager {
private dateService;
private config;
private positionUtils;
private workSchedule;
constructor(dateService: DateService, config: Configuration, positionUtils: PositionUtils);
/**
* Get work hours for a specific date
*/
getWorkHoursForDate(date: Date): IDayWorkHours | 'off';
/**
* Get work hours for multiple dates (used by GridManager)
*/
getWorkHoursForDateRange(dates: Date[]): Map<string, IDayWorkHours | 'off'>;
/**
* Calculate CSS custom properties for non-work hour overlays using PositionUtils
*/
calculateNonWorkHoursStyle(workHours: IDayWorkHours | 'off'): {
beforeWorkHeight: number;
afterWorkTop: number;
} | null;
/**
* Calculate CSS custom properties for work hours overlay using PositionUtils
*/
calculateWorkHoursStyle(workHours: IDayWorkHours | 'off'): {
top: number;
height: number;
} | null;
/**
* Load work schedule from JSON (future implementation)
*/
loadWorkSchedule(jsonData: IWorkScheduleConfig): Promise<void>;
/**
* Get current work schedule configuration
*/
getWorkSchedule(): IWorkScheduleConfig;
/**
* Convert Date to day name key
*/
private getDayName;
}

View file

@ -0,0 +1,108 @@
// Work hours management for per-column scheduling
/**
* Manages work hours scheduling with weekly defaults and date-specific overrides
*/
export class WorkHoursManager {
constructor(dateService, config, 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) {
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) {
const workHoursMap = new Map();
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) {
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) {
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) {
this.workSchedule = jsonData;
}
/**
* Get current work schedule configuration
*/
getWorkSchedule() {
return this.workSchedule;
}
/**
* Convert Date to day name key
*/
getDayName(date) {
const dayNames = [
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'
];
return dayNames[date.getDay()];
}
}
//# sourceMappingURL=WorkHoursManager.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"WorkHoursManager.js","sourceRoot":"","sources":["../../../src/managers/WorkHoursManager.ts"],"names":[],"mappings":"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"}

View file

@ -0,0 +1,47 @@
import { IEventBus } from '../types/CalendarTypes';
import { Configuration } from '../configurations/CalendarConfig';
/**
* WorkweekPresetsManager - Manages workweek preset UI and state
*
* RESPONSIBILITY:
* ===============
* This manager owns all logic related to the <swp-workweek-presets> UI element.
* It follows the principle that each functional UI element has its own manager.
*
* RESPONSIBILITIES:
* - Owns WORK_WEEK_PRESETS data
* - Handles button clicks on swp-preset-button elements
* - Manages current workweek preset state
* - Validates preset IDs
* - Emits WORKWEEK_CHANGED events
* - Updates button UI states (data-active attributes)
*
* EVENT FLOW:
* ===========
* User clicks button changePreset() validate update state emit event update UI
*
* SUBSCRIBERS:
* ============
* - ConfigManager: Updates CSS variables (--grid-columns)
* - GridManager: Re-renders grid with new column count
* - CalendarManager: Relays to header update (via workweek:header-update)
* - HeaderManager: Updates date headers
*/
export declare class WorkweekPresetsManager {
private eventBus;
private config;
private buttonListeners;
constructor(eventBus: IEventBus, config: Configuration);
/**
* Setup click listeners on all workweek preset buttons
*/
private setupButtonListeners;
/**
* Change the active workweek preset
*/
private changePreset;
/**
* Update button states (data-active attributes)
*/
private updateButtonStates;
}

View file

@ -0,0 +1,95 @@
import { CoreEvents } from '../constants/CoreEvents';
import { WORK_WEEK_PRESETS } from '../configurations/CalendarConfig';
/**
* WorkweekPresetsManager - Manages workweek preset UI and state
*
* RESPONSIBILITY:
* ===============
* This manager owns all logic related to the <swp-workweek-presets> UI element.
* It follows the principle that each functional UI element has its own manager.
*
* RESPONSIBILITIES:
* - Owns WORK_WEEK_PRESETS data
* - Handles button clicks on swp-preset-button elements
* - Manages current workweek preset state
* - Validates preset IDs
* - Emits WORKWEEK_CHANGED events
* - Updates button UI states (data-active attributes)
*
* EVENT FLOW:
* ===========
* User clicks button changePreset() validate update state emit event update UI
*
* SUBSCRIBERS:
* ============
* - ConfigManager: Updates CSS variables (--grid-columns)
* - GridManager: Re-renders grid with new column count
* - CalendarManager: Relays to header update (via workweek:header-update)
* - HeaderManager: Updates date headers
*/
export class WorkweekPresetsManager {
constructor(eventBus, config) {
this.buttonListeners = new Map();
this.eventBus = eventBus;
this.config = config;
this.setupButtonListeners();
}
/**
* Setup click listeners on all workweek preset buttons
*/
setupButtonListeners() {
const buttons = document.querySelectorAll('swp-preset-button[data-workweek]');
buttons.forEach(button => {
const clickHandler = (event) => {
event.preventDefault();
const presetId = button.getAttribute('data-workweek');
if (presetId) {
this.changePreset(presetId);
}
};
button.addEventListener('click', clickHandler);
this.buttonListeners.set(button, clickHandler);
});
// Initialize button states
this.updateButtonStates();
}
/**
* Change the active workweek preset
*/
changePreset(presetId) {
if (!WORK_WEEK_PRESETS[presetId]) {
console.warn(`Invalid preset ID "${presetId}"`);
return;
}
if (presetId === this.config.currentWorkWeek) {
return; // No change
}
const previousPresetId = this.config.currentWorkWeek;
this.config.currentWorkWeek = presetId;
const settings = WORK_WEEK_PRESETS[presetId];
// Update button UI states
this.updateButtonStates();
// Emit event for subscribers
this.eventBus.emit(CoreEvents.WORKWEEK_CHANGED, {
workWeekId: presetId,
previousWorkWeekId: previousPresetId,
settings: settings
});
}
/**
* Update button states (data-active attributes)
*/
updateButtonStates() {
const buttons = document.querySelectorAll('swp-preset-button[data-workweek]');
buttons.forEach(button => {
const buttonPresetId = button.getAttribute('data-workweek');
if (buttonPresetId === this.config.currentWorkWeek) {
button.setAttribute('data-active', 'true');
}
else {
button.removeAttribute('data-active');
}
});
}
}
//# sourceMappingURL=WorkweekPresetsManager.js.map

View file

@ -0,0 +1 @@
{"version":3,"file":"WorkweekPresetsManager.js","sourceRoot":"","sources":["../../../src/managers/WorkweekPresetsManager.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAErD,OAAO,EAAE,iBAAiB,EAAiB,MAAM,kCAAkC,CAAC;AAEpF;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,OAAO,sBAAsB;IAKjC,YAAY,QAAmB,EAAE,MAAqB;QAF9C,oBAAe,GAAgC,IAAI,GAAG,EAAE,CAAC;QAG/D,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QAErB,IAAI,CAAC,oBAAoB,EAAE,CAAC;IAC9B,CAAC;IAED;;OAEG;IACK,oBAAoB;QAC1B,MAAM,OAAO,GAAG,QAAQ,CAAC,gBAAgB,CAAC,kCAAkC,CAAC,CAAC;QAE9E,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE;YACvB,MAAM,YAAY,GAAG,CAAC,KAAY,EAAE,EAAE;gBACpC,KAAK,CAAC,cAAc,EAAE,CAAC;gBACvB,MAAM,QAAQ,GAAG,MAAM,CAAC,YAAY,CAAC,eAAe,CAAC,CAAC;gBACtD,IAAI,QAAQ,EAAE,CAAC;oBACb,IAAI,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;gBAC9B,CAAC;YACH,CAAC,CAAC;YAEF,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,YAAY,CAAC,CAAC;YAC/C,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;QACjD,CAAC,CAAC,CAAC;QAEH,2BAA2B;QAC3B,IAAI,CAAC,kBAAkB,EAAE,CAAC;IAC5B,CAAC;IAED;;OAEG;IACK,YAAY,CAAC,QAAgB;QACnC,IAAI,CAAC,iBAAiB,CAAC,QAAQ,CAAC,EAAE,CAAC;YACjC,OAAO,CAAC,IAAI,CAAC,sBAAsB,QAAQ,GAAG,CAAC,CAAC;YAChD,OAAO;QACT,CAAC;QAED,IAAI,QAAQ,KAAK,IAAI,CAAC,MAAM,CAAC,eAAe,EAAE,CAAC;YAC7C,OAAO,CAAC,YAAY;QACtB,CAAC;QAED,MAAM,gBAAgB,GAAG,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC;QACrD,IAAI,CAAC,MAAM,CAAC,eAAe,GAAG,QAAQ,CAAC;QAEvC,MAAM,QAAQ,GAAG,iBAAiB,CAAC,QAAQ,CAAC,CAAC;QAE7C,0BAA0B;QAC1B,IAAI,CAAC,kBAAkB,EAAE,CAAC;QAE1B,6BAA6B;QAC7B,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,gBAAgB,EAAE;YAC9C,UAAU,EAAE,QAAQ;YACpB,kBAAkB,EAAE,gBAAgB;YACpC,QAAQ,EAAE,QAAQ;SACnB,CAAC,CAAC;IACL,CAAC;IAED;;OAEG;IACK,kBAAkB;QACxB,MAAM,OAAO,GAAG,QAAQ,CAAC,gBAAgB,CAAC,kCAAkC,CAAC,CAAC;QAE9E,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE;YACvB,MAAM,cAAc,GAAG,MAAM,CAAC,YAAY,CAAC,eAAe,CAAC,CAAC;YAE5D,IAAI,cAAc,KAAK,IAAI,CAAC,MAAM,CAAC,eAAe,EAAE,CAAC;gBACnD,MAAM,CAAC,YAAY,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC;YAC7C,CAAC;iBAAM,CAAC;gBACN,MAAM,CAAC,eAAe,CAAC,aAAa,CAAC,CAAC;YACxC,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;CAEF"}