Refactors calendar event rendering and management
Improves code organization and maintainability by separating concerns related to all-day event rendering, header management, and event resizing. Moves all-day event rendering logic into a dedicated `AllDayEventRenderer` class, utilizing the factory pattern for event element creation. Refactors `AllDayManager` to handle all-day row height animations, separated from `HeaderManager`. Removes the `ResizeManager` and related functionality. These changes aim to reduce code duplication, improve testability, and enhance the overall architecture of the calendar component.
This commit is contained in:
parent
e0b83ebd70
commit
c07d83d86f
13 changed files with 599 additions and 1306 deletions
183
docs/code-improvement-plan.md
Normal file
183
docs/code-improvement-plan.md
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
# Kodeanalyse og Forbedringsplan - Calendar System
|
||||||
|
|
||||||
|
## Overordnet Vurdering
|
||||||
|
Koden er generelt velstruktureret med god separation of concerns. Der er dog stadig nogle områder med duplikering og potentiale for yderligere optimering.
|
||||||
|
|
||||||
|
## Positive Observationer ✅
|
||||||
|
|
||||||
|
### 1. God Arkitektur
|
||||||
|
- **Factory Pattern**: SwpEventElement bruger factory pattern korrekt
|
||||||
|
- **Event-driven**: Konsistent brug af EventBus for kommunikation
|
||||||
|
- **Caching**: God brug af caching i DragDropManager og EventManager
|
||||||
|
- **Separation**: AllDayManager er korrekt separeret fra HeaderManager
|
||||||
|
|
||||||
|
### 2. Performance Optimering
|
||||||
|
- **DOM Caching**: DragDropManager cacher DOM elementer effektivt
|
||||||
|
- **Event Throttling**: Implementeret i flere managers
|
||||||
|
- **Lazy Loading**: Smart brug af lazy loading patterns
|
||||||
|
|
||||||
|
### 3. TypeScript Best Practices
|
||||||
|
- Stærk typing med interfaces
|
||||||
|
- God brug af branded types (EventId)
|
||||||
|
- Konsistent error handling
|
||||||
|
|
||||||
|
## Identificerede Problemer og Forbedringsforslag 🔧
|
||||||
|
|
||||||
|
### 1. Duplikeret Time Formatting
|
||||||
|
**Problem**: `formatTime()` metode findes i:
|
||||||
|
- EventRenderer.ts (linje 280-297)
|
||||||
|
- SwpEventElement.ts (linje 44-50)
|
||||||
|
|
||||||
|
**Løsning**: Opret en central TimeFormatter utility:
|
||||||
|
```typescript
|
||||||
|
// src/utils/TimeFormatter.ts
|
||||||
|
export class TimeFormatter {
|
||||||
|
static formatTime(input: number | Date | string): string {
|
||||||
|
// Centraliseret implementation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Duplikeret Cache Management
|
||||||
|
**Problem**: Lignende cache patterns i:
|
||||||
|
- AllDayManager (linje 11-76)
|
||||||
|
- HeaderManager
|
||||||
|
- GridRenderer
|
||||||
|
|
||||||
|
**Løsning**: Generisk CacheManager:
|
||||||
|
```typescript
|
||||||
|
// src/utils/CacheManager.ts
|
||||||
|
export class DOMCacheManager<T extends Record<string, HTMLElement | null>> {
|
||||||
|
private cache: T;
|
||||||
|
|
||||||
|
constructor(initialCache: T) {
|
||||||
|
this.cache = initialCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
get<K extends keyof T>(key: K, selector?: string): T[K] {
|
||||||
|
if (!this.cache[key] && selector) {
|
||||||
|
this.cache[key] = document.querySelector(selector) as T[K];
|
||||||
|
}
|
||||||
|
return this.cache[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
Object.keys(this.cache).forEach(key => {
|
||||||
|
this.cache[key as keyof T] = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Overlap Detection Kompleksitet
|
||||||
|
**Problem**: EventRenderer har stadig "new_" prefixed metoder som indikerer ufærdig refactoring
|
||||||
|
|
||||||
|
**Løsning**:
|
||||||
|
- Fjern "new_" prefix fra metoderne
|
||||||
|
- Flyt al overlap logik til OverlapDetector
|
||||||
|
- Simplificer EventRenderer
|
||||||
|
|
||||||
|
### 4. Grid Positioning Beregninger
|
||||||
|
**Problem**: Grid position beregninger gentages flere steder
|
||||||
|
|
||||||
|
**Løsning**: Centralisér i GridPositionCalculator:
|
||||||
|
```typescript
|
||||||
|
// src/utils/GridPositionCalculator.ts
|
||||||
|
export class GridPositionCalculator {
|
||||||
|
static calculateEventPosition(event: CalendarEvent): { top: number; height: number }
|
||||||
|
static calculateSnapPosition(y: number, snapInterval: number): number
|
||||||
|
static pixelsToMinutes(pixels: number, hourHeight: number): number
|
||||||
|
static minutesToPixels(minutes: number, hourHeight: number): number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Event Element Creation
|
||||||
|
**Problem**: SwpEventElement kunne forenkles yderligere
|
||||||
|
|
||||||
|
**Forslag**:
|
||||||
|
- Tilføj flere factory metoder for forskellige event typer
|
||||||
|
- Implementer builder pattern for komplekse events
|
||||||
|
|
||||||
|
### 6. All-Day Event Row Calculation
|
||||||
|
**Problem**: AllDayManager har kompleks row calculation logik (linje 108-143)
|
||||||
|
|
||||||
|
**Løsning**: Udtræk til separat utility:
|
||||||
|
```typescript
|
||||||
|
// src/utils/AllDayRowCalculator.ts
|
||||||
|
export class AllDayRowCalculator {
|
||||||
|
static calculateRequiredRows(events: HTMLElement[]): number
|
||||||
|
static expandEventsByDate(events: HTMLElement[]): Record<string, string[]>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Manglende Unit Tests
|
||||||
|
**Problem**: Ingen test filer fundet
|
||||||
|
|
||||||
|
**Løsning**: Tilføj tests for kritiske utilities:
|
||||||
|
- TimeFormatter
|
||||||
|
- GridPositionCalculator
|
||||||
|
- OverlapDetector
|
||||||
|
- AllDayRowCalculator
|
||||||
|
|
||||||
|
## Prioriteret Handlingsplan
|
||||||
|
|
||||||
|
### Fase 1: Utilities (Høj Prioritet)
|
||||||
|
1. ✅ SwpEventElement factory (allerede implementeret)
|
||||||
|
2. ⬜ TimeFormatter utility
|
||||||
|
3. ⬜ DOMCacheManager
|
||||||
|
4. ⬜ GridPositionCalculator
|
||||||
|
|
||||||
|
### Fase 2: Refactoring (Medium Prioritet)
|
||||||
|
5. ⬜ Fjern "new_" prefix fra EventRenderer metoder
|
||||||
|
6. ⬜ Simplificer AllDayManager med AllDayRowCalculator
|
||||||
|
7. ⬜ Konsolider overlap detection
|
||||||
|
|
||||||
|
### Fase 3: Testing & Dokumentation (Lav Prioritet)
|
||||||
|
8. ⬜ Unit tests for utilities
|
||||||
|
9. ⬜ JSDoc dokumentation
|
||||||
|
10. ⬜ Performance benchmarks
|
||||||
|
|
||||||
|
## Arkitektur Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Utilities Layer] --> B[TimeFormatter]
|
||||||
|
A --> C[DOMCacheManager]
|
||||||
|
A --> D[GridPositionCalculator]
|
||||||
|
A --> E[AllDayRowCalculator]
|
||||||
|
|
||||||
|
F[Managers] --> A
|
||||||
|
G[Renderers] --> A
|
||||||
|
H[Elements] --> A
|
||||||
|
|
||||||
|
F --> I[EventManager]
|
||||||
|
F --> J[DragDropManager]
|
||||||
|
F --> K[AllDayManager]
|
||||||
|
|
||||||
|
G --> L[EventRenderer]
|
||||||
|
G --> M[AllDayEventRenderer]
|
||||||
|
|
||||||
|
H --> N[SwpEventElement]
|
||||||
|
H --> O[SwpAllDayEventElement]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Forbedringer
|
||||||
|
|
||||||
|
### 1. Event Delegation
|
||||||
|
Overvej at bruge event delegation i stedet for individuelle event listeners på hver event element.
|
||||||
|
|
||||||
|
### 2. Virtual Scrolling
|
||||||
|
For kalendere med mange events, implementer virtual scrolling.
|
||||||
|
|
||||||
|
### 3. Web Workers
|
||||||
|
Overvej at flytte tunge beregninger til Web Workers.
|
||||||
|
|
||||||
|
## Konklusion
|
||||||
|
|
||||||
|
Koden er generelt i god stand med solid arkitektur. De foreslåede forbedringer vil:
|
||||||
|
- Reducere code duplication med 30-40%
|
||||||
|
- Forbedre maintainability
|
||||||
|
- Gøre koden mere testbar
|
||||||
|
- Forbedre performance marginalt
|
||||||
|
|
||||||
|
Estimeret tid for implementering: 2-3 dage for alle forbedringer.
|
||||||
220
src/managers/AllDayManager.ts
Normal file
220
src/managers/AllDayManager.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
// All-day row height management and animations
|
||||||
|
|
||||||
|
import { eventBus } from '../core/EventBus';
|
||||||
|
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AllDayManager - Handles all-day row height animations and management
|
||||||
|
* Separated from HeaderManager for clean responsibility separation
|
||||||
|
*/
|
||||||
|
export class AllDayManager {
|
||||||
|
private cachedAllDayContainer: HTMLElement | null = null;
|
||||||
|
private cachedCalendarHeader: HTMLElement | null = null;
|
||||||
|
private cachedHeaderSpacer: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Bind methods for event listeners
|
||||||
|
this.checkAndAnimateAllDayHeight = this.checkAndAnimateAllDayHeight.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached all-day container element
|
||||||
|
*/
|
||||||
|
private getAllDayContainer(): HTMLElement | null {
|
||||||
|
if (!this.cachedAllDayContainer) {
|
||||||
|
const calendarHeader = this.getCalendarHeader();
|
||||||
|
if (calendarHeader) {
|
||||||
|
this.cachedAllDayContainer = calendarHeader.querySelector('swp-allday-container');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.cachedAllDayContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached calendar header element
|
||||||
|
*/
|
||||||
|
private getCalendarHeader(): HTMLElement | null {
|
||||||
|
if (!this.cachedCalendarHeader) {
|
||||||
|
this.cachedCalendarHeader = document.querySelector('swp-calendar-header');
|
||||||
|
}
|
||||||
|
return this.cachedCalendarHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached header spacer element
|
||||||
|
*/
|
||||||
|
private getHeaderSpacer(): HTMLElement | null {
|
||||||
|
if (!this.cachedHeaderSpacer) {
|
||||||
|
this.cachedHeaderSpacer = document.querySelector('swp-header-spacer');
|
||||||
|
}
|
||||||
|
return this.cachedHeaderSpacer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate all-day height based on number of rows
|
||||||
|
*/
|
||||||
|
private calculateAllDayHeight(targetRows: number): {
|
||||||
|
targetHeight: number;
|
||||||
|
currentHeight: number;
|
||||||
|
heightDifference: number;
|
||||||
|
} {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const targetHeight = targetRows * ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT;
|
||||||
|
const currentHeight = parseInt(getComputedStyle(root).getPropertyValue('--all-day-row-height') || '0');
|
||||||
|
const heightDifference = targetHeight - currentHeight;
|
||||||
|
|
||||||
|
return { targetHeight, currentHeight, heightDifference };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cached DOM elements (call when DOM structure changes)
|
||||||
|
*/
|
||||||
|
private clearCache(): void {
|
||||||
|
this.cachedCalendarHeader = null;
|
||||||
|
this.cachedAllDayContainer = null;
|
||||||
|
this.cachedHeaderSpacer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand all-day row to show events
|
||||||
|
*/
|
||||||
|
public expandAllDayRow(): void {
|
||||||
|
const { currentHeight } = this.calculateAllDayHeight(0);
|
||||||
|
|
||||||
|
if (currentHeight === 0) {
|
||||||
|
this.checkAndAnimateAllDayHeight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse all-day row when no events
|
||||||
|
*/
|
||||||
|
public collapseAllDayRow(): void {
|
||||||
|
this.animateToRows(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check current all-day events and animate to correct height
|
||||||
|
*/
|
||||||
|
public checkAndAnimateAllDayHeight(): void {
|
||||||
|
const container = this.getAllDayContainer();
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const allDayEvents = container.querySelectorAll('swp-allday-event');
|
||||||
|
|
||||||
|
// Calculate required rows - 0 if no events (will collapse)
|
||||||
|
let maxRows = 0;
|
||||||
|
|
||||||
|
if (allDayEvents.length > 0) {
|
||||||
|
// Expand events to all dates they span and group by date
|
||||||
|
const expandedEventsByDate: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
(Array.from(allDayEvents) as HTMLElement[]).forEach((event: HTMLElement) => {
|
||||||
|
const startISO = event.dataset.start || '';
|
||||||
|
const endISO = event.dataset.end || startISO;
|
||||||
|
const eventId = event.dataset.eventId || '';
|
||||||
|
|
||||||
|
// Extract dates from ISO strings
|
||||||
|
const startDate = startISO.split('T')[0]; // YYYY-MM-DD
|
||||||
|
const endDate = endISO.split('T')[0]; // YYYY-MM-DD
|
||||||
|
|
||||||
|
// Loop through all dates from start to end
|
||||||
|
let current = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
|
||||||
|
while (current <= end) {
|
||||||
|
const dateStr = current.toISOString().split('T')[0]; // YYYY-MM-DD format
|
||||||
|
|
||||||
|
if (!expandedEventsByDate[dateStr]) {
|
||||||
|
expandedEventsByDate[dateStr] = [];
|
||||||
|
}
|
||||||
|
expandedEventsByDate[dateStr].push(eventId);
|
||||||
|
|
||||||
|
// Move to next day
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find max rows needed
|
||||||
|
maxRows = Math.max(
|
||||||
|
...Object.values(expandedEventsByDate).map(ids => ids?.length || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate to required rows (0 = collapse, >0 = expand)
|
||||||
|
this.animateToRows(maxRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animate all-day container to specific number of rows
|
||||||
|
*/
|
||||||
|
public animateToRows(targetRows: number): void {
|
||||||
|
const { targetHeight, currentHeight, heightDifference } = this.calculateAllDayHeight(targetRows);
|
||||||
|
|
||||||
|
if (targetHeight === currentHeight) return; // No animation needed
|
||||||
|
|
||||||
|
console.log(`🎬 All-day height animation: ${currentHeight}px → ${targetHeight}px (${Math.ceil(currentHeight / ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT)} → ${targetRows} rows)`);
|
||||||
|
|
||||||
|
// Get cached elements
|
||||||
|
const calendarHeader = this.getCalendarHeader();
|
||||||
|
const headerSpacer = this.getHeaderSpacer();
|
||||||
|
const allDayContainer = this.getAllDayContainer();
|
||||||
|
|
||||||
|
if (!calendarHeader || !allDayContainer) return;
|
||||||
|
|
||||||
|
// Get current parent height for animation
|
||||||
|
const currentParentHeight = parseFloat(getComputedStyle(calendarHeader).height);
|
||||||
|
const targetParentHeight = currentParentHeight + heightDifference;
|
||||||
|
|
||||||
|
const animations = [
|
||||||
|
calendarHeader.animate([
|
||||||
|
{ height: `${currentParentHeight}px` },
|
||||||
|
{ height: `${targetParentHeight}px` }
|
||||||
|
], {
|
||||||
|
duration: 300,
|
||||||
|
easing: 'ease-out',
|
||||||
|
fill: 'forwards'
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add spacer animation if spacer exists
|
||||||
|
if (headerSpacer) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const currentSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + currentHeight;
|
||||||
|
const targetSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + targetHeight;
|
||||||
|
|
||||||
|
animations.push(
|
||||||
|
headerSpacer.animate([
|
||||||
|
{ height: `${currentSpacerHeight}px` },
|
||||||
|
{ height: `${targetSpacerHeight}px` }
|
||||||
|
], {
|
||||||
|
duration: 300,
|
||||||
|
easing: 'ease-out',
|
||||||
|
fill: 'forwards'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update row height when all-day events change
|
||||||
|
*/
|
||||||
|
public updateRowHeight(): void {
|
||||||
|
this.checkAndAnimateAllDayHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up cached elements and resources
|
||||||
|
*/
|
||||||
|
public destroy(): void {
|
||||||
|
this.clearCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,32 +50,12 @@ export class HeaderManager {
|
||||||
|
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
// Optimized element detection
|
// Optimized element detection - only handle day headers
|
||||||
const dayHeader = target.closest('swp-day-header');
|
const dayHeader = target.closest('swp-day-header');
|
||||||
const allDayContainer = target.closest('swp-allday-container');
|
|
||||||
|
|
||||||
if (dayHeader || allDayContainer) {
|
if (dayHeader) {
|
||||||
let hoveredElement: HTMLElement;
|
const hoveredElement = dayHeader as HTMLElement;
|
||||||
let targetDate: string | undefined;
|
const targetDate = hoveredElement.dataset.date;
|
||||||
|
|
||||||
if (dayHeader) {
|
|
||||||
hoveredElement = dayHeader as HTMLElement;
|
|
||||||
targetDate = hoveredElement.dataset.date;
|
|
||||||
} else if (allDayContainer) {
|
|
||||||
hoveredElement = allDayContainer as HTMLElement;
|
|
||||||
|
|
||||||
// Optimized day calculation using cached header rect
|
|
||||||
const headerRect = calendarHeader.getBoundingClientRect();
|
|
||||||
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
|
|
||||||
const mouseX = (event as MouseEvent).clientX - headerRect.left;
|
|
||||||
const dayWidth = headerRect.width / dayHeaders.length;
|
|
||||||
const dayIndex = Math.max(0, Math.min(dayHeaders.length - 1, Math.floor(mouseX / dayWidth)));
|
|
||||||
|
|
||||||
const targetDayHeader = dayHeaders[dayIndex] as HTMLElement;
|
|
||||||
targetDate = targetDayHeader?.dataset.date;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get header renderer for coordination
|
// Get header renderer for coordination
|
||||||
const calendarType = calendarConfig.getCalendarMode();
|
const calendarType = calendarConfig.getCalendarMode();
|
||||||
|
|
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
import { calendarConfig } from '../core/CalendarConfig';
|
|
||||||
import { eventBus } from '../core/EventBus';
|
|
||||||
import { IEventBus } from '../types/CalendarTypes';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resize state interface
|
|
||||||
*/
|
|
||||||
interface ResizeState {
|
|
||||||
element: HTMLElement;
|
|
||||||
handle: 'top' | 'bottom';
|
|
||||||
startY: number;
|
|
||||||
originalTop: number;
|
|
||||||
originalHeight: number;
|
|
||||||
originalStartTime: Date;
|
|
||||||
originalEndTime: Date;
|
|
||||||
minHeightPx: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ResizeManager - Handles event resizing functionality
|
|
||||||
*/
|
|
||||||
export class ResizeManager {
|
|
||||||
private resizeState: ResizeState | null = null;
|
|
||||||
private readonly MIN_EVENT_DURATION_MINUTES = 15;
|
|
||||||
|
|
||||||
constructor(private eventBus: IEventBus) {
|
|
||||||
// Bind methods for event listeners
|
|
||||||
this.handleResize = this.handleResize.bind(this);
|
|
||||||
this.endResize = this.endResize.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup dynamic resize handles that are only created when needed
|
|
||||||
* @param eventElement - Event element to add resize handles to
|
|
||||||
*/
|
|
||||||
public setupResizeHandles(eventElement: HTMLElement): void {
|
|
||||||
// Variables to track resize handles
|
|
||||||
let topHandle: HTMLElement | null = null;
|
|
||||||
let bottomHandle: HTMLElement | null = null;
|
|
||||||
|
|
||||||
console.log('Setting up dynamic resize handles for event:', eventElement.dataset.eventId);
|
|
||||||
|
|
||||||
// Create resize handles on first mouseover
|
|
||||||
eventElement.addEventListener('mouseenter', () => {
|
|
||||||
if (!topHandle && !bottomHandle) {
|
|
||||||
topHandle = document.createElement('swp-resize-handle');
|
|
||||||
topHandle.className = 'swp-resize-handle swp-resize-top';
|
|
||||||
|
|
||||||
bottomHandle = document.createElement('swp-resize-handle');
|
|
||||||
bottomHandle.className = 'swp-resize-handle swp-resize-bottom';
|
|
||||||
|
|
||||||
// Add mousedown listeners for resize functionality
|
|
||||||
topHandle.addEventListener('mousedown', (e: MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.startResize(eventElement, 'top', e);
|
|
||||||
});
|
|
||||||
|
|
||||||
bottomHandle.addEventListener('mousedown', (e: MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.startResize(eventElement, 'bottom', e);
|
|
||||||
});
|
|
||||||
|
|
||||||
eventElement.appendChild(topHandle);
|
|
||||||
eventElement.appendChild(bottomHandle);
|
|
||||||
console.log('Created resize handles for event:', eventElement.dataset.eventId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show/hide handles based on mouse position
|
|
||||||
eventElement.addEventListener('mousemove', (e: MouseEvent) => {
|
|
||||||
if (!topHandle || !bottomHandle) return;
|
|
||||||
|
|
||||||
const rect = eventElement.getBoundingClientRect();
|
|
||||||
const mouseY = e.clientY - rect.top;
|
|
||||||
const eventHeight = rect.height;
|
|
||||||
const topZone = eventHeight * 0.2;
|
|
||||||
const bottomZone = eventHeight * 0.8;
|
|
||||||
|
|
||||||
// Show top handle in upper 20%
|
|
||||||
if (mouseY < topZone) {
|
|
||||||
topHandle.style.opacity = '1';
|
|
||||||
bottomHandle.style.opacity = '0';
|
|
||||||
}
|
|
||||||
// Show bottom handle in lower 20%
|
|
||||||
else if (mouseY > bottomZone) {
|
|
||||||
topHandle.style.opacity = '0';
|
|
||||||
bottomHandle.style.opacity = '1';
|
|
||||||
}
|
|
||||||
// Hide both if mouse is in middle
|
|
||||||
else {
|
|
||||||
topHandle.style.opacity = '0';
|
|
||||||
bottomHandle.style.opacity = '0';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hide handles when mouse leaves event (but only if not in resize mode)
|
|
||||||
eventElement.addEventListener('mouseleave', () => {
|
|
||||||
console.log('Mouse LEAVE event:', eventElement.dataset.eventId);
|
|
||||||
if (!this.resizeState && topHandle && bottomHandle) {
|
|
||||||
topHandle.style.opacity = '0';
|
|
||||||
bottomHandle.style.opacity = '0';
|
|
||||||
console.log('Hidden resize handles for event:', eventElement.dataset.eventId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start resize operation
|
|
||||||
*/
|
|
||||||
private startResize(eventElement: HTMLElement, handle: 'top' | 'bottom', e: MouseEvent): void {
|
|
||||||
const gridSettings = calendarConfig.getGridSettings();
|
|
||||||
const minHeightPx = (this.MIN_EVENT_DURATION_MINUTES / 60) * gridSettings.hourHeight;
|
|
||||||
|
|
||||||
this.resizeState = {
|
|
||||||
element: eventElement,
|
|
||||||
handle: handle,
|
|
||||||
startY: e.clientY,
|
|
||||||
originalTop: parseFloat(eventElement.style.top),
|
|
||||||
originalHeight: parseFloat(eventElement.style.height),
|
|
||||||
originalStartTime: new Date(eventElement.dataset.start || ''),
|
|
||||||
originalEndTime: new Date(eventElement.dataset.end || ''),
|
|
||||||
minHeightPx: minHeightPx
|
|
||||||
};
|
|
||||||
|
|
||||||
// Global listeners for resize
|
|
||||||
document.addEventListener('mousemove', this.handleResize);
|
|
||||||
document.addEventListener('mouseup', this.endResize);
|
|
||||||
|
|
||||||
// Add resize cursor to body
|
|
||||||
document.body.style.cursor = handle === 'top' ? 'n-resize' : 's-resize';
|
|
||||||
|
|
||||||
console.log('Starting resize:', handle, 'element:', eventElement.dataset.eventId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle resize drag
|
|
||||||
*/
|
|
||||||
private handleResize(e: MouseEvent): void {
|
|
||||||
if (!this.resizeState) return;
|
|
||||||
|
|
||||||
const deltaY = e.clientY - this.resizeState.startY;
|
|
||||||
const snappedDelta = this.snapToGrid(deltaY);
|
|
||||||
const gridSettings = calendarConfig.getGridSettings();
|
|
||||||
|
|
||||||
if (this.resizeState.handle === 'top') {
|
|
||||||
// Resize from top
|
|
||||||
const newTop = this.resizeState.originalTop + snappedDelta;
|
|
||||||
const newHeight = this.resizeState.originalHeight - snappedDelta;
|
|
||||||
|
|
||||||
// Check minimum height
|
|
||||||
if (newHeight >= this.resizeState.minHeightPx && newTop >= 0) {
|
|
||||||
this.resizeState.element.style.top = newTop + 'px';
|
|
||||||
this.resizeState.element.style.height = newHeight + 'px';
|
|
||||||
|
|
||||||
// Update times
|
|
||||||
const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60;
|
|
||||||
const newStartTime = this.addMinutes(this.resizeState.originalStartTime, minutesDelta);
|
|
||||||
this.updateEventDisplay(this.resizeState.element, newStartTime, this.resizeState.originalEndTime);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Resize from bottom
|
|
||||||
const newHeight = this.resizeState.originalHeight + snappedDelta;
|
|
||||||
|
|
||||||
// Check minimum height
|
|
||||||
if (newHeight >= this.resizeState.minHeightPx) {
|
|
||||||
this.resizeState.element.style.height = newHeight + 'px';
|
|
||||||
|
|
||||||
// Update times
|
|
||||||
const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60;
|
|
||||||
const newEndTime = this.addMinutes(this.resizeState.originalEndTime, minutesDelta);
|
|
||||||
this.updateEventDisplay(this.resizeState.element, this.resizeState.originalStartTime, newEndTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End resize operation
|
|
||||||
*/
|
|
||||||
private endResize(): void {
|
|
||||||
if (!this.resizeState) return;
|
|
||||||
|
|
||||||
// Get final times from element
|
|
||||||
const finalStart = this.resizeState.element.dataset.start;
|
|
||||||
const finalEnd = this.resizeState.element.dataset.end;
|
|
||||||
|
|
||||||
console.log('Ending resize:', this.resizeState.element.dataset.eventId, 'New times:', finalStart, finalEnd);
|
|
||||||
|
|
||||||
// Emit event with new times
|
|
||||||
this.eventBus.emit('event:resized', {
|
|
||||||
eventId: this.resizeState.element.dataset.eventId,
|
|
||||||
newStart: finalStart,
|
|
||||||
newEnd: finalEnd
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
document.removeEventListener('mousemove', this.handleResize);
|
|
||||||
document.removeEventListener('mouseup', this.endResize);
|
|
||||||
document.body.style.cursor = '';
|
|
||||||
this.resizeState = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snap delta to grid intervals
|
|
||||||
*/
|
|
||||||
private snapToGrid(deltaY: number): number {
|
|
||||||
const gridSettings = calendarConfig.getGridSettings();
|
|
||||||
const snapInterval = gridSettings.snapInterval;
|
|
||||||
const hourHeight = gridSettings.hourHeight;
|
|
||||||
const snapDistancePx = (snapInterval / 60) * hourHeight;
|
|
||||||
return Math.round(deltaY / snapDistancePx) * snapDistancePx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update event display during resize
|
|
||||||
*/
|
|
||||||
private updateEventDisplay(element: HTMLElement, startTime: Date, endTime: Date): void {
|
|
||||||
// Calculate new duration in minutes
|
|
||||||
const durationMinutes = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
|
|
||||||
|
|
||||||
// Update dataset
|
|
||||||
element.dataset.start = startTime.toISOString();
|
|
||||||
element.dataset.end = endTime.toISOString();
|
|
||||||
element.dataset.duration = durationMinutes.toString();
|
|
||||||
|
|
||||||
// Update visual time
|
|
||||||
const timeElement = element.querySelector('swp-event-time');
|
|
||||||
if (timeElement) {
|
|
||||||
const startStr = this.formatTime(startTime.toISOString());
|
|
||||||
const endStr = this.formatTime(endTime.toISOString());
|
|
||||||
timeElement.textContent = `${startStr} - ${endStr}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add minutes to a date
|
|
||||||
*/
|
|
||||||
private addMinutes(date: Date, minutes: number): Date {
|
|
||||||
return new Date(date.getTime() + minutes * 60000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format time for display
|
|
||||||
*/
|
|
||||||
private formatTime(input: Date | string): string {
|
|
||||||
let hours: number;
|
|
||||||
let minutes: number;
|
|
||||||
|
|
||||||
if (input instanceof Date) {
|
|
||||||
hours = input.getHours();
|
|
||||||
minutes = input.getMinutes();
|
|
||||||
} else {
|
|
||||||
// Date or ISO string input
|
|
||||||
const date = typeof input === 'string' ? new Date(input) : input;
|
|
||||||
hours = date.getHours();
|
|
||||||
minutes = date.getMinutes();
|
|
||||||
}
|
|
||||||
|
|
||||||
const period = hours >= 12 ? 'PM' : 'AM';
|
|
||||||
const displayHours = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
|
|
||||||
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
174
src/renderers/AllDayEventRenderer.ts
Normal file
174
src/renderers/AllDayEventRenderer.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
// All-day event rendering using factory pattern
|
||||||
|
|
||||||
|
import { CalendarEvent } from '../types/CalendarTypes';
|
||||||
|
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
|
||||||
|
import { DateCalculator } from '../utils/DateCalculator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AllDayEventRenderer - Handles rendering of all-day events in header row
|
||||||
|
* Uses factory pattern with SwpAllDayEventElement for clean DOM creation
|
||||||
|
*/
|
||||||
|
export class AllDayEventRenderer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render all-day events in the header container
|
||||||
|
*/
|
||||||
|
public renderAllDayEvents(events: CalendarEvent[], container: HTMLElement): void {
|
||||||
|
const allDayEvents = events.filter(event => event.allDay);
|
||||||
|
|
||||||
|
// Find the calendar header
|
||||||
|
const calendarHeader = container.querySelector('swp-calendar-header');
|
||||||
|
if (!calendarHeader) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create all-day container
|
||||||
|
let allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement;
|
||||||
|
if (!allDayContainer) {
|
||||||
|
allDayContainer = document.createElement('swp-allday-container');
|
||||||
|
calendarHeader.appendChild(allDayContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing events
|
||||||
|
allDayContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (allDayEvents.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build date to column mapping
|
||||||
|
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
|
||||||
|
const dateToColumnMap = new Map<string, number>();
|
||||||
|
|
||||||
|
dayHeaders.forEach((header, index) => {
|
||||||
|
const dateStr = (header as HTMLElement).dataset.date;
|
||||||
|
if (dateStr) {
|
||||||
|
dateToColumnMap.set(dateStr, index + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate grid positioning for events
|
||||||
|
const eventPlacements = this.calculateEventPlacements(allDayEvents, dateToColumnMap);
|
||||||
|
|
||||||
|
// Render events using factory pattern
|
||||||
|
eventPlacements.forEach(({ event, gridColumn, gridRow }) => {
|
||||||
|
const eventDateStr = DateCalculator.formatISODate(event.start);
|
||||||
|
const swpAllDayEvent = SwpAllDayEventElement.fromCalendarEvent(event, eventDateStr);
|
||||||
|
const allDayElement = swpAllDayEvent.getElement();
|
||||||
|
|
||||||
|
// Apply grid positioning
|
||||||
|
(allDayElement as HTMLElement).style.gridColumn = gridColumn;
|
||||||
|
(allDayElement as HTMLElement).style.gridRow = gridRow.toString();
|
||||||
|
|
||||||
|
// Use event metadata for color if available
|
||||||
|
if (event.metadata?.color) {
|
||||||
|
(allDayElement as HTMLElement).style.backgroundColor = event.metadata.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
allDayContainer.appendChild(allDayElement);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate grid positioning for all-day events with overlap detection
|
||||||
|
*/
|
||||||
|
private calculateEventPlacements(events: CalendarEvent[], dateToColumnMap: Map<string, number>) {
|
||||||
|
// Calculate spans for each event
|
||||||
|
const eventItems = events.map(event => {
|
||||||
|
const eventDateStr = DateCalculator.formatISODate(event.start);
|
||||||
|
const endDateStr = DateCalculator.formatISODate(event.end);
|
||||||
|
|
||||||
|
const startColumn = dateToColumnMap.get(eventDateStr);
|
||||||
|
const endColumn = dateToColumnMap.get(endDateStr);
|
||||||
|
|
||||||
|
if (startColumn === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnSpan = endColumn !== undefined && endColumn >= startColumn
|
||||||
|
? endColumn - startColumn + 1
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
event,
|
||||||
|
span: {
|
||||||
|
startColumn: startColumn,
|
||||||
|
columnSpan: columnSpan
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}).filter(item => item !== null) as Array<{
|
||||||
|
event: CalendarEvent;
|
||||||
|
span: { startColumn: number; columnSpan: number };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Calculate row placement to avoid overlaps
|
||||||
|
interface EventPlacement {
|
||||||
|
event: CalendarEvent;
|
||||||
|
gridColumn: string;
|
||||||
|
gridRow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventPlacements: EventPlacement[] = [];
|
||||||
|
|
||||||
|
eventItems.forEach(eventItem => {
|
||||||
|
let assignedRow = 1;
|
||||||
|
|
||||||
|
// Find first available row
|
||||||
|
while (true) {
|
||||||
|
// Check if this row has any conflicts
|
||||||
|
const rowEvents = eventPlacements.filter(p => p.gridRow === assignedRow);
|
||||||
|
|
||||||
|
const hasOverlap = rowEvents.some(rowEvent => {
|
||||||
|
// Parse the existing grid column to check overlap
|
||||||
|
const existingSpan = this.parseGridColumn(rowEvent.gridColumn);
|
||||||
|
return this.spansOverlap(eventItem.span, existingSpan);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasOverlap) {
|
||||||
|
break; // Found available row
|
||||||
|
}
|
||||||
|
assignedRow++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridColumn = eventItem.span.columnSpan > 1
|
||||||
|
? `${eventItem.span.startColumn} / span ${eventItem.span.columnSpan}`
|
||||||
|
: `${eventItem.span.startColumn}`;
|
||||||
|
|
||||||
|
eventPlacements.push({
|
||||||
|
event: eventItem.event,
|
||||||
|
gridColumn,
|
||||||
|
gridRow: assignedRow
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return eventPlacements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two column spans overlap
|
||||||
|
*/
|
||||||
|
private spansOverlap(span1: { startColumn: number; columnSpan: number }, span2: { startColumn: number; columnSpan: number }): boolean {
|
||||||
|
const span1End = span1.startColumn + span1.columnSpan - 1;
|
||||||
|
const span2End = span2.startColumn + span2.columnSpan - 1;
|
||||||
|
|
||||||
|
return !(span1End < span2.startColumn || span2End < span1.startColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse grid column string back to span object
|
||||||
|
*/
|
||||||
|
private parseGridColumn(gridColumn: string): { startColumn: number; columnSpan: number } {
|
||||||
|
if (gridColumn.includes('span')) {
|
||||||
|
const parts = gridColumn.split(' / span ');
|
||||||
|
return {
|
||||||
|
startColumn: parseInt(parts[0]),
|
||||||
|
columnSpan: parseInt(parts[1])
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
startColumn: parseInt(gridColumn),
|
||||||
|
columnSpan: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ import { DateCalculator } from '../utils/DateCalculator';
|
||||||
import { eventBus } from '../core/EventBus';
|
import { eventBus } from '../core/EventBus';
|
||||||
import { CoreEvents } from '../constants/CoreEvents';
|
import { CoreEvents } from '../constants/CoreEvents';
|
||||||
import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector';
|
import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector';
|
||||||
import { ResizeManager } from '../managers/ResizeManager';
|
|
||||||
import { SwpEventElement, SwpAllDayEventElement } from '../elements/SwpEventElement';
|
import { SwpEventElement, SwpAllDayEventElement } from '../elements/SwpEventElement';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -28,14 +27,12 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
||||||
private originalEvent: HTMLElement | null = null;
|
private originalEvent: HTMLElement | null = null;
|
||||||
|
|
||||||
// Resize manager
|
// Resize manager
|
||||||
private resizeManager: ResizeManager;
|
|
||||||
|
|
||||||
constructor(dateCalculator?: DateCalculator) {
|
constructor(dateCalculator?: DateCalculator) {
|
||||||
if (!dateCalculator) {
|
if (!dateCalculator) {
|
||||||
DateCalculator.initialize(calendarConfig);
|
DateCalculator.initialize(calendarConfig);
|
||||||
}
|
}
|
||||||
this.dateCalculator = dateCalculator || new DateCalculator();
|
this.dateCalculator = dateCalculator || new DateCalculator();
|
||||||
this.resizeManager = new ResizeManager(eventBus);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -135,40 +132,13 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
||||||
this.handleColumnChange(eventId, newColumn);
|
this.handleColumnChange(eventId, newColumn);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle convert to all-day
|
|
||||||
eventBus.on('drag:convert-to-allday', (event) => {
|
|
||||||
const { eventId, targetDate, headerRenderer } = (event as CustomEvent).detail;
|
|
||||||
this.handleConvertToAllDay(eventId, targetDate, headerRenderer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle convert to timed event
|
|
||||||
eventBus.on('drag:convert-to-timed', (event) => {
|
|
||||||
const { eventId, targetColumn, targetY } = (event as CustomEvent).detail;
|
|
||||||
this.handleConvertToTimed(eventId, targetColumn, targetY);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle all-day to timed conversion (when leaving header)
|
|
||||||
eventBus.on('drag:convert-allday-to-timed', (event) => {
|
|
||||||
const { eventId, originalElement } = (event as CustomEvent).detail;
|
|
||||||
this.handleConvertAllDayToTimed(eventId, originalElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle navigation period change (when slide animation completes)
|
// Handle navigation period change (when slide animation completes)
|
||||||
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => {
|
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => {
|
||||||
// Animate all-day height after navigation completes
|
// Animate all-day height after navigation completes
|
||||||
this.triggerAllDayHeightAnimation();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger all-day height animation without creating new renderer instance
|
|
||||||
*/
|
|
||||||
private triggerAllDayHeightAnimation(): void {
|
|
||||||
import('./HeaderRenderer').then(({ DateHeaderRenderer }) => {
|
|
||||||
const headerRenderer = new DateHeaderRenderer();
|
|
||||||
headerRenderer.checkAndAnimateAllDayHeight();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup method for proper resource management
|
* Cleanup method for proper resource management
|
||||||
|
|
@ -688,250 +658,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
||||||
/**
|
/**
|
||||||
* Handle conversion to all-day event
|
* Handle conversion to all-day event
|
||||||
*/
|
*/
|
||||||
private handleConvertToAllDay(eventId: string, targetDate: string, headerRenderer: any): void {
|
|
||||||
if (!this.draggedClone) return;
|
|
||||||
|
|
||||||
// Only convert once
|
|
||||||
if (this.draggedClone.dataset.displayType === 'allday') return;
|
|
||||||
|
|
||||||
// Transform clone to all-day format
|
|
||||||
this.transformCloneToAllDay(this.draggedClone, targetDate);
|
|
||||||
|
|
||||||
// Expand header if needed
|
|
||||||
headerRenderer.addToAllDay(this.draggedClone.parentElement);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform clone from timed to all-day event by modifying existing element
|
|
||||||
*/
|
|
||||||
private transformCloneToAllDay(clone: HTMLElement, targetDate: string): void {
|
|
||||||
const calendarHeader = document.querySelector('swp-calendar-header');
|
|
||||||
if (!calendarHeader) return;
|
|
||||||
|
|
||||||
// Find all-day container
|
|
||||||
const allDayContainer = calendarHeader.querySelector('swp-allday-container');
|
|
||||||
if (!allDayContainer) return;
|
|
||||||
|
|
||||||
// Extract event data for transformation
|
|
||||||
const titleElement = clone.querySelector('swp-event-title');
|
|
||||||
const eventTitle = titleElement ? titleElement.textContent || 'Untitled' : 'Untitled';
|
|
||||||
|
|
||||||
const timeElement = clone.querySelector('swp-event-time');
|
|
||||||
const eventDuration = timeElement ? timeElement.getAttribute('data-duration') || '' : '';
|
|
||||||
|
|
||||||
// Calculate column index for CSS Grid positioning
|
|
||||||
const dayHeaders = document.querySelectorAll('swp-day-header');
|
|
||||||
let columnIndex = 1;
|
|
||||||
dayHeaders.forEach((header, index) => {
|
|
||||||
if ((header as HTMLElement).dataset.date === targetDate) {
|
|
||||||
columnIndex = index + 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transform the existing element in-place instead of creating new one
|
|
||||||
// Update dataset for all-day format
|
|
||||||
clone.dataset.displayType = "allday";
|
|
||||||
clone.dataset.allDay = "true";
|
|
||||||
clone.dataset.start = `${targetDate}T00:00:00`;
|
|
||||||
clone.dataset.end = `${targetDate}T23:59:59`;
|
|
||||||
if (eventDuration) {
|
|
||||||
clone.dataset.duration = eventDuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change content to all-day format (just title)
|
|
||||||
clone.innerHTML = eventTitle;
|
|
||||||
|
|
||||||
// Clear timed event positioning
|
|
||||||
clone.style.position = '';
|
|
||||||
clone.style.top = '';
|
|
||||||
clone.style.height = '';
|
|
||||||
clone.style.left = '';
|
|
||||||
clone.style.right = '';
|
|
||||||
|
|
||||||
// Apply CSS grid positioning for all-day
|
|
||||||
clone.style.gridColumn = columnIndex.toString();
|
|
||||||
|
|
||||||
// Move element to all-day container
|
|
||||||
const parent = clone.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
parent.removeChild(clone);
|
|
||||||
}
|
|
||||||
allDayContainer.appendChild(clone);
|
|
||||||
|
|
||||||
// draggedClone reference stays the same since it's the same element
|
|
||||||
|
|
||||||
// Check if height animation is needed
|
|
||||||
this.triggerAllDayHeightAnimation();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle conversion from all-day to timed event
|
|
||||||
*/
|
|
||||||
private handleConvertToTimed(eventId: string, targetColumn: string, targetY: number): void {
|
|
||||||
if (!this.draggedClone) return;
|
|
||||||
|
|
||||||
// Only convert if it's an all-day event
|
|
||||||
if (this.draggedClone.dataset.displayType !== 'allday') return;
|
|
||||||
|
|
||||||
// Transform clone to timed format
|
|
||||||
this.transformAllDayToTimed(this.draggedClone, targetColumn, targetY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle all-day to timed conversion by transforming existing element
|
|
||||||
*/
|
|
||||||
private handleConvertAllDayToTimed(eventId: string, originalElement: HTMLElement): void {
|
|
||||||
if (!this.draggedClone) return;
|
|
||||||
|
|
||||||
// Only convert if it's an all-day event
|
|
||||||
if (this.draggedClone.dataset.displayType !== 'allday') return;
|
|
||||||
|
|
||||||
// Transform the existing element instead of creating a new one
|
|
||||||
this.transformAllDayToTimedInPlace(this.draggedClone);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform all-day element to timed by modifying existing element in place
|
|
||||||
*/
|
|
||||||
private transformAllDayToTimedInPlace(allDayElement: HTMLElement): void {
|
|
||||||
// Extract event data
|
|
||||||
const eventId = allDayElement.dataset.eventId || '';
|
|
||||||
const eventTitle = allDayElement.dataset.title || allDayElement.textContent || 'Untitled';
|
|
||||||
const eventType = allDayElement.dataset.type || 'work';
|
|
||||||
const duration = parseInt(allDayElement.dataset.duration || '60');
|
|
||||||
|
|
||||||
// Calculate position for timed event (use current time or 9 AM default)
|
|
||||||
const now = new Date();
|
|
||||||
const startHour = now.getHours() || 9;
|
|
||||||
const startMinutes = now.getMinutes() || 0;
|
|
||||||
|
|
||||||
// Transform the existing element in-place instead of creating new one
|
|
||||||
// Update dataset for timed format
|
|
||||||
allDayElement.dataset.displayType = "timed";
|
|
||||||
delete allDayElement.dataset.allDay;
|
|
||||||
|
|
||||||
// Set timed event structure
|
|
||||||
const startTime = this.formatTime(new Date(2000, 0, 1, startHour, startMinutes));
|
|
||||||
const endTime = this.formatTime(new Date(2000, 0, 1, startHour, startMinutes + duration));
|
|
||||||
|
|
||||||
allDayElement.innerHTML = `
|
|
||||||
<swp-event-time data-duration="${duration}">${startTime} - ${endTime}</swp-event-time>
|
|
||||||
<swp-event-title>${eventTitle}</swp-event-title>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Clear all-day positioning
|
|
||||||
allDayElement.style.gridColumn = '';
|
|
||||||
|
|
||||||
// Apply timed event positioning
|
|
||||||
allDayElement.style.position = 'absolute';
|
|
||||||
allDayElement.style.left = '2px';
|
|
||||||
allDayElement.style.right = '2px';
|
|
||||||
allDayElement.style.top = '100px'; // Default position, will be adjusted by drag system
|
|
||||||
allDayElement.style.height = '57px'; // Default height for 1 hour
|
|
||||||
|
|
||||||
// Find a day column to place the element (try to use today's column)
|
|
||||||
const columns = document.querySelectorAll('swp-day-column');
|
|
||||||
let targetColumn = columns[0]; // fallback
|
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
columns.forEach(col => {
|
|
||||||
if ((col as HTMLElement).dataset.date === today) {
|
|
||||||
targetColumn = col;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const eventsLayer = targetColumn?.querySelector('swp-events-layer');
|
|
||||||
|
|
||||||
// Move element from all-day container to events layer
|
|
||||||
const parent = allDayElement.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
parent.removeChild(allDayElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to events layer
|
|
||||||
if (eventsLayer) {
|
|
||||||
eventsLayer.appendChild(allDayElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// draggedClone reference stays the same since it's the same element
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform clone from all-day to timed event
|
|
||||||
*/
|
|
||||||
private transformAllDayToTimed(allDayClone: HTMLElement, targetColumn: string, targetY: number): void {
|
|
||||||
// Find target column element
|
|
||||||
const columnElement = document.querySelector(`swp-day-column[data-date="${targetColumn}"]`);
|
|
||||||
if (!columnElement) return;
|
|
||||||
|
|
||||||
const eventsLayer = columnElement.querySelector('swp-events-layer');
|
|
||||||
if (!eventsLayer) return;
|
|
||||||
|
|
||||||
// Extract event data from all-day element
|
|
||||||
const eventId = allDayClone.dataset.eventId || '';
|
|
||||||
const eventTitle = allDayClone.dataset.title || allDayClone.textContent || 'Untitled';
|
|
||||||
const eventType = allDayClone.dataset.type || 'work';
|
|
||||||
|
|
||||||
// Calculate time from Y position
|
|
||||||
const gridSettings = calendarConfig.getGridSettings();
|
|
||||||
const hourHeight = gridSettings.hourHeight;
|
|
||||||
const dayStartHour = gridSettings.dayStartHour;
|
|
||||||
const snapInterval = gridSettings.snapInterval;
|
|
||||||
|
|
||||||
// Calculate start time from position
|
|
||||||
const minutesFromGridStart = (targetY / hourHeight) * 60;
|
|
||||||
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
|
|
||||||
const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval;
|
|
||||||
|
|
||||||
// Use default duration or extract from dataset
|
|
||||||
const duration = parseInt(allDayClone.dataset.duration || '60');
|
|
||||||
const endMinutes = snappedStartMinutes + duration;
|
|
||||||
|
|
||||||
// Create dates with target column date
|
|
||||||
const columnDate = new Date(targetColumn + 'T00:00:00');
|
|
||||||
const startDate = new Date(columnDate);
|
|
||||||
startDate.setMinutes(snappedStartMinutes);
|
|
||||||
|
|
||||||
const endDate = new Date(columnDate);
|
|
||||||
endDate.setMinutes(endMinutes);
|
|
||||||
|
|
||||||
// Create CalendarEvent object for helper methods
|
|
||||||
const tempEvent: CalendarEvent = {
|
|
||||||
id: eventId,
|
|
||||||
title: eventTitle,
|
|
||||||
start: startDate,
|
|
||||||
end: endDate,
|
|
||||||
type: eventType,
|
|
||||||
allDay: false,
|
|
||||||
syncStatus: 'synced',
|
|
||||||
metadata: {
|
|
||||||
duration: duration
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create timed event using factory
|
|
||||||
const swpTimedEvent = SwpEventElement.fromCalendarEvent(tempEvent);
|
|
||||||
const timedEvent = swpTimedEvent.getElement();
|
|
||||||
|
|
||||||
// Set additional drag-specific attributes
|
|
||||||
timedEvent.dataset.originalDuration = duration.toString();
|
|
||||||
|
|
||||||
// Apply drag styling and positioning
|
|
||||||
this.applyDragStyling(timedEvent);
|
|
||||||
const eventHeight = (duration / 60) * hourHeight - 3;
|
|
||||||
timedEvent.style.height = `${eventHeight}px`;
|
|
||||||
timedEvent.style.top = `${targetY}px`;
|
|
||||||
|
|
||||||
// Remove all-day element
|
|
||||||
allDayClone.remove();
|
|
||||||
|
|
||||||
// Add timed event to events layer
|
|
||||||
eventsLayer.appendChild(timedEvent);
|
|
||||||
|
|
||||||
// Update reference
|
|
||||||
this.draggedClone = timedEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fade out and remove element
|
* Fade out and remove element
|
||||||
|
|
@ -953,19 +679,15 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
||||||
// clearEvents() would remove events from all containers, breaking the animation
|
// clearEvents() would remove events from all containers, breaking the animation
|
||||||
// Events are now rendered directly into the new container without clearing
|
// Events are now rendered directly into the new container without clearing
|
||||||
|
|
||||||
// Separate all-day events from regular events
|
// Only handle regular (non-all-day) events
|
||||||
const allDayEvents = events.filter(event => event.allDay);
|
|
||||||
const regularEvents = events.filter(event => !event.allDay);
|
|
||||||
|
|
||||||
|
|
||||||
// Always call renderAllDayEvents to ensure height is set correctly (even to 0)
|
|
||||||
this.renderAllDayEvents(allDayEvents, container);
|
|
||||||
|
|
||||||
// Find columns in the specific container for regular events
|
// Find columns in the specific container for regular events
|
||||||
const columns = this.getColumns(container);
|
const columns = this.getColumns(container);
|
||||||
|
|
||||||
columns.forEach(column => {
|
columns.forEach(column => {
|
||||||
const columnEvents = this.getEventsForColumn(column, regularEvents);
|
const columnEvents = this.getEventsForColumn(column, events);
|
||||||
|
|
||||||
const eventsLayer = column.querySelector('swp-events-layer');
|
const eventsLayer = column.querySelector('swp-events-layer');
|
||||||
if (eventsLayer) {
|
if (eventsLayer) {
|
||||||
|
|
@ -979,101 +701,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
||||||
protected abstract getColumns(container: HTMLElement): HTMLElement[];
|
protected abstract getColumns(container: HTMLElement): HTMLElement[];
|
||||||
protected abstract getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[];
|
protected abstract getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[];
|
||||||
|
|
||||||
/**
|
|
||||||
* Render all-day events in the header row 2
|
|
||||||
*/
|
|
||||||
protected renderAllDayEvents(allDayEvents: CalendarEvent[], container: HTMLElement): void {
|
|
||||||
|
|
||||||
// Find the calendar header
|
|
||||||
const calendarHeader = container.querySelector('swp-calendar-header');
|
|
||||||
if (!calendarHeader) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the all-day container (should always exist now)
|
|
||||||
const allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement;
|
|
||||||
if (!allDayContainer) {
|
|
||||||
console.warn('All-day container not found - this should not happen');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing events
|
|
||||||
allDayContainer.innerHTML = '';
|
|
||||||
|
|
||||||
if (allDayEvents.length === 0) {
|
|
||||||
// No events - container exists but is empty and hidden
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build date to column mapping
|
|
||||||
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
|
|
||||||
const dateToColumnMap = new Map<string, number>();
|
|
||||||
|
|
||||||
dayHeaders.forEach((header, index) => {
|
|
||||||
const dateStr = (header as any).dataset.date;
|
|
||||||
if (dateStr) {
|
|
||||||
dateToColumnMap.set(dateStr, index + 1); // 1-based column index
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate grid spans for all events
|
|
||||||
const eventSpans = allDayEvents.map(event => ({
|
|
||||||
event,
|
|
||||||
span: this.calculateEventGridSpan(event, dateToColumnMap)
|
|
||||||
})).filter(item => item.span.columnSpan > 0); // Remove events outside visible range
|
|
||||||
|
|
||||||
// Simple row assignment using overlap detection
|
|
||||||
const eventPlacements: Array<{ event: CalendarEvent, span: { startColumn: number, columnSpan: number }, row: number }> = [];
|
|
||||||
|
|
||||||
eventSpans.forEach(eventItem => {
|
|
||||||
let assignedRow = 1;
|
|
||||||
|
|
||||||
// Find first row where this event doesn't overlap with any existing event
|
|
||||||
while (true) {
|
|
||||||
const rowEvents = eventPlacements.filter(item => item.row === assignedRow);
|
|
||||||
const hasOverlap = rowEvents.some(rowEvent =>
|
|
||||||
this.spansOverlap(eventItem.span, rowEvent.span)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasOverlap) {
|
|
||||||
break; // Found available row
|
|
||||||
}
|
|
||||||
assignedRow++;
|
|
||||||
}
|
|
||||||
|
|
||||||
eventPlacements.push({
|
|
||||||
event: eventItem.event,
|
|
||||||
span: eventItem.span,
|
|
||||||
row: assignedRow
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get max row needed
|
|
||||||
const maxRow = Math.max(...eventPlacements.map(item => item.row), 1);
|
|
||||||
|
|
||||||
// Place events directly in the single container
|
|
||||||
eventPlacements.forEach(({ event, span, row }) => {
|
|
||||||
// Create all-day event using factory
|
|
||||||
const eventDateStr = DateCalculator.formatISODate(event.start);
|
|
||||||
const swpAllDayEvent = SwpAllDayEventElement.fromCalendarEvent(event, eventDateStr);
|
|
||||||
const allDayEvent = swpAllDayEvent.getElement();
|
|
||||||
|
|
||||||
// Override grid position for spanning events
|
|
||||||
(allDayEvent as HTMLElement).style.gridColumn = span.columnSpan > 1
|
|
||||||
? `${span.startColumn} / span ${span.columnSpan}`
|
|
||||||
: `${span.startColumn}`;
|
|
||||||
(allDayEvent as HTMLElement).style.gridRow = row.toString();
|
|
||||||
|
|
||||||
// Use event metadata for color if available
|
|
||||||
if (event.metadata?.color) {
|
|
||||||
(allDayEvent as HTMLElement).style.backgroundColor = event.metadata.color;
|
|
||||||
}
|
|
||||||
|
|
||||||
allDayContainer.appendChild(allDayEvent);
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
protected renderEvent(event: CalendarEvent): HTMLElement {
|
protected renderEvent(event: CalendarEvent): HTMLElement {
|
||||||
const swpEvent = SwpEventElement.fromCalendarEvent(event);
|
const swpEvent = SwpEventElement.fromCalendarEvent(event);
|
||||||
|
|
@ -1082,7 +709,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
||||||
// Setup resize handles on first mouseover only
|
// Setup resize handles on first mouseover only
|
||||||
eventElement.addEventListener('mouseover', () => {
|
eventElement.addEventListener('mouseover', () => {
|
||||||
if (eventElement.dataset.hasResizeHandlers !== 'true') {
|
if (eventElement.dataset.hasResizeHandlers !== 'true') {
|
||||||
this.resizeManager.setupResizeHandles(eventElement);
|
|
||||||
eventElement.dataset.hasResizeHandlers = 'true';
|
eventElement.dataset.hasResizeHandlers = 'true';
|
||||||
}
|
}
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
|
@ -1113,51 +739,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
||||||
return { top, height };
|
return { top, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate grid column span for event
|
|
||||||
*/
|
|
||||||
private calculateEventGridSpan(event: CalendarEvent, dateToColumnMap: Map<string, number>): { startColumn: number, columnSpan: number } {
|
|
||||||
const startDateKey = DateCalculator.formatISODate(event.start);
|
|
||||||
const startColumn = dateToColumnMap.get(startDateKey);
|
|
||||||
|
|
||||||
if (!startColumn) {
|
|
||||||
return { startColumn: 0, columnSpan: 0 }; // Event outside visible range
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate span by checking each day
|
|
||||||
let endColumn = startColumn;
|
|
||||||
const currentDate = new Date(event.start);
|
|
||||||
|
|
||||||
while (currentDate <= event.end) {
|
|
||||||
currentDate.setDate(currentDate.getDate() + 1);
|
|
||||||
const dateKey = DateCalculator.formatISODate(currentDate);
|
|
||||||
const col = dateToColumnMap.get(dateKey);
|
|
||||||
if (col) {
|
|
||||||
endColumn = col;
|
|
||||||
} else {
|
|
||||||
break; // Event extends beyond visible range
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const columnSpan = endColumn - startColumn + 1;
|
|
||||||
return { startColumn, columnSpan };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if two column spans overlap (for all-day events)
|
|
||||||
*/
|
|
||||||
private spansOverlap(event1Span: { startColumn: number, columnSpan: number }, event2Span: { startColumn: number, columnSpan: number }): boolean {
|
|
||||||
const event1End = event1Span.startColumn + event1Span.columnSpan - 1;
|
|
||||||
const event2End = event2Span.startColumn + event2Span.columnSpan - 1;
|
|
||||||
|
|
||||||
return !(event1End < event2Span.startColumn || event2End < event1Span.startColumn);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
clearEvents(container?: HTMLElement): void {
|
clearEvents(container?: HTMLElement): void {
|
||||||
const selector = 'swp-event, swp-event-group';
|
const selector = 'swp-event, swp-event-group';
|
||||||
const existingEvents = container
|
const existingEvents = container
|
||||||
|
|
|
||||||
|
|
@ -154,8 +154,6 @@ export class GridRenderer {
|
||||||
|
|
||||||
headerRenderer.render(calendarHeader, context);
|
headerRenderer.render(calendarHeader, context);
|
||||||
|
|
||||||
// Always ensure all-day containers exist for all days
|
|
||||||
headerRenderer.ensureAllDayContainers(calendarHeader);
|
|
||||||
|
|
||||||
// Setup only grid-related event listeners
|
// Setup only grid-related event listeners
|
||||||
this.setupGridEventListeners();
|
this.setupGridEventListeners();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
// Header rendering strategy interface and implementations
|
// Header rendering strategy interface and implementations
|
||||||
|
|
||||||
import { CalendarConfig, ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
|
import { CalendarConfig } from '../core/CalendarConfig';
|
||||||
import { eventBus } from '../core/EventBus';
|
|
||||||
import { ResourceCalendarData } from '../types/CalendarTypes';
|
import { ResourceCalendarData } from '../types/CalendarTypes';
|
||||||
import { DateCalculator } from '../utils/DateCalculator';
|
import { DateCalculator } from '../utils/DateCalculator';
|
||||||
|
|
||||||
|
|
@ -10,232 +9,8 @@ import { DateCalculator } from '../utils/DateCalculator';
|
||||||
*/
|
*/
|
||||||
export interface HeaderRenderer {
|
export interface HeaderRenderer {
|
||||||
render(calendarHeader: HTMLElement, context: HeaderRenderContext): void;
|
render(calendarHeader: HTMLElement, context: HeaderRenderContext): void;
|
||||||
addToAllDay(dayHeader: HTMLElement): void;
|
|
||||||
ensureAllDayContainers(calendarHeader: HTMLElement): void;
|
|
||||||
checkAndAnimateAllDayHeight(): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Base class with shared addToAllDay implementation
|
|
||||||
*/
|
|
||||||
export abstract class BaseHeaderRenderer implements HeaderRenderer {
|
|
||||||
// Cached DOM elements to avoid redundant queries
|
|
||||||
private cachedCalendarHeader: HTMLElement | null = null;
|
|
||||||
private cachedAllDayContainer: HTMLElement | null = null;
|
|
||||||
private cachedHeaderSpacer: HTMLElement | null = null;
|
|
||||||
|
|
||||||
abstract render(calendarHeader: HTMLElement, context: HeaderRenderContext): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached calendar header element
|
|
||||||
*/
|
|
||||||
private getCalendarHeader(): HTMLElement | null {
|
|
||||||
if (!this.cachedCalendarHeader) {
|
|
||||||
this.cachedCalendarHeader = document.querySelector('swp-calendar-header');
|
|
||||||
}
|
|
||||||
return this.cachedCalendarHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached all-day container element
|
|
||||||
*/
|
|
||||||
private getAllDayContainer(): HTMLElement | null {
|
|
||||||
if (!this.cachedAllDayContainer) {
|
|
||||||
const calendarHeader = this.getCalendarHeader();
|
|
||||||
if (calendarHeader) {
|
|
||||||
this.cachedAllDayContainer = calendarHeader.querySelector('swp-allday-container');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.cachedAllDayContainer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached header spacer element
|
|
||||||
*/
|
|
||||||
private getHeaderSpacer(): HTMLElement | null {
|
|
||||||
if (!this.cachedHeaderSpacer) {
|
|
||||||
this.cachedHeaderSpacer = document.querySelector('swp-header-spacer');
|
|
||||||
}
|
|
||||||
return this.cachedHeaderSpacer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate all-day height based on number of rows
|
|
||||||
*/
|
|
||||||
private calculateAllDayHeight(targetRows: number): {
|
|
||||||
targetHeight: number;
|
|
||||||
currentHeight: number;
|
|
||||||
heightDifference: number;
|
|
||||||
} {
|
|
||||||
const root = document.documentElement;
|
|
||||||
const targetHeight = targetRows * ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT;
|
|
||||||
const currentHeight = parseInt(getComputedStyle(root).getPropertyValue('--all-day-row-height') || '0');
|
|
||||||
const heightDifference = targetHeight - currentHeight;
|
|
||||||
|
|
||||||
return { targetHeight, currentHeight, heightDifference };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear cached DOM elements (call when DOM structure changes)
|
|
||||||
*/
|
|
||||||
private clearCache(): void {
|
|
||||||
this.cachedCalendarHeader = null;
|
|
||||||
this.cachedAllDayContainer = null;
|
|
||||||
this.cachedHeaderSpacer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expand header to show all-day row
|
|
||||||
*/
|
|
||||||
addToAllDay(dayHeader: HTMLElement): void {
|
|
||||||
const { currentHeight } = this.calculateAllDayHeight(0);
|
|
||||||
|
|
||||||
if (currentHeight === 0) {
|
|
||||||
// Find the calendar header element to animate
|
|
||||||
const calendarHeader = dayHeader.closest('swp-calendar-header') as HTMLElement;
|
|
||||||
if (calendarHeader) {
|
|
||||||
// Ensure container exists BEFORE animation
|
|
||||||
this.createAllDayMainStructure(calendarHeader);
|
|
||||||
this.checkAndAnimateAllDayHeight();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure all-day containers exist - always create them during header rendering
|
|
||||||
*/
|
|
||||||
ensureAllDayContainers(calendarHeader: HTMLElement): void {
|
|
||||||
this.createAllDayMainStructure(calendarHeader);
|
|
||||||
}
|
|
||||||
|
|
||||||
checkAndAnimateAllDayHeight(): void {
|
|
||||||
const container = this.getAllDayContainer();
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const allDayEvents = container.querySelectorAll('swp-allday-event');
|
|
||||||
|
|
||||||
// Calculate required rows - 0 if no events (will collapse)
|
|
||||||
let maxRows = 0;
|
|
||||||
|
|
||||||
if (allDayEvents.length > 0) {
|
|
||||||
// Expand events to all dates they span and group by date
|
|
||||||
const expandedEventsByDate: Record<string, string[]> = {};
|
|
||||||
|
|
||||||
(Array.from(allDayEvents) as HTMLElement[]).forEach((event: HTMLElement) => {
|
|
||||||
const startISO = event.dataset.start || '';
|
|
||||||
const endISO = event.dataset.end || startISO;
|
|
||||||
const eventId = event.dataset.eventId || '';
|
|
||||||
|
|
||||||
// Extract dates from ISO strings
|
|
||||||
const startDate = startISO.split('T')[0]; // YYYY-MM-DD
|
|
||||||
const endDate = endISO.split('T')[0]; // YYYY-MM-DD
|
|
||||||
|
|
||||||
// Loop through all dates from start to end
|
|
||||||
let current = new Date(startDate);
|
|
||||||
const end = new Date(endDate);
|
|
||||||
|
|
||||||
while (current <= end) {
|
|
||||||
const dateStr = current.toISOString().split('T')[0]; // YYYY-MM-DD format
|
|
||||||
|
|
||||||
if (!expandedEventsByDate[dateStr]) {
|
|
||||||
expandedEventsByDate[dateStr] = [];
|
|
||||||
}
|
|
||||||
expandedEventsByDate[dateStr].push(eventId);
|
|
||||||
|
|
||||||
// Move to next day
|
|
||||||
current.setDate(current.getDate() + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find max rows needed
|
|
||||||
maxRows = Math.max(
|
|
||||||
...Object.values(expandedEventsByDate).map(ids => ids?.length || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animate to required rows (0 = collapse, >0 = expand)
|
|
||||||
this.animateToRows(maxRows);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Animate all-day container to specific number of rows
|
|
||||||
*/
|
|
||||||
animateToRows(targetRows: number): void {
|
|
||||||
const { targetHeight, currentHeight, heightDifference } = this.calculateAllDayHeight(targetRows);
|
|
||||||
|
|
||||||
if (targetHeight === currentHeight) return; // No animation needed
|
|
||||||
|
|
||||||
console.log(`🎬 All-day height animation starting: ${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: 300,
|
|
||||||
easing: 'ease-out',
|
|
||||||
fill: 'forwards'
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add spacer animation if spacer exists
|
|
||||||
if (headerSpacer) {
|
|
||||||
const root = document.documentElement;
|
|
||||||
const currentSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + currentHeight;
|
|
||||||
const targetSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + targetHeight;
|
|
||||||
|
|
||||||
animations.push(
|
|
||||||
headerSpacer.animate([
|
|
||||||
{ height: `${currentSpacerHeight}px` },
|
|
||||||
{ height: `${targetSpacerHeight}px` }
|
|
||||||
], {
|
|
||||||
duration: 300,
|
|
||||||
easing: 'ease-out',
|
|
||||||
fill: 'forwards'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private createAllDayMainStructure(calendarHeader: HTMLElement): void {
|
|
||||||
// Check if container already exists
|
|
||||||
let container = calendarHeader.querySelector('swp-allday-container');
|
|
||||||
|
|
||||||
if (!container) {
|
|
||||||
// Create simple all-day container (initially hidden)
|
|
||||||
container = document.createElement('swp-allday-container');
|
|
||||||
calendarHeader.appendChild(container);
|
|
||||||
// Clear cache since DOM structure changed
|
|
||||||
this.clearCache();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public cleanup method for cached elements
|
|
||||||
*/
|
|
||||||
public destroy(): void {
|
|
||||||
this.clearCache();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context for header rendering
|
* Context for header rendering
|
||||||
|
|
@ -249,7 +24,7 @@ export interface HeaderRenderContext {
|
||||||
/**
|
/**
|
||||||
* Date-based header renderer (original functionality)
|
* Date-based header renderer (original functionality)
|
||||||
*/
|
*/
|
||||||
export class DateHeaderRenderer extends BaseHeaderRenderer {
|
export class DateHeaderRenderer implements HeaderRenderer {
|
||||||
private dateCalculator!: DateCalculator;
|
private dateCalculator!: DateCalculator;
|
||||||
|
|
||||||
render(calendarHeader: HTMLElement, context: HeaderRenderContext): void {
|
render(calendarHeader: HTMLElement, context: HeaderRenderContext): void {
|
||||||
|
|
@ -279,16 +54,13 @@ export class DateHeaderRenderer extends BaseHeaderRenderer {
|
||||||
|
|
||||||
calendarHeader.appendChild(header);
|
calendarHeader.appendChild(header);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always create all-day container after rendering headers
|
|
||||||
this.ensureAllDayContainers(calendarHeader);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resource-based header renderer
|
* Resource-based header renderer
|
||||||
*/
|
*/
|
||||||
export class ResourceHeaderRenderer extends BaseHeaderRenderer {
|
export class ResourceHeaderRenderer implements HeaderRenderer {
|
||||||
render(calendarHeader: HTMLElement, context: HeaderRenderContext): void {
|
render(calendarHeader: HTMLElement, context: HeaderRenderContext): void {
|
||||||
const { resourceData } = context;
|
const { resourceData } = context;
|
||||||
|
|
||||||
|
|
@ -310,8 +82,5 @@ export class ResourceHeaderRenderer extends BaseHeaderRenderer {
|
||||||
|
|
||||||
calendarHeader.appendChild(header);
|
calendarHeader.appendChild(header);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always create all-day container after rendering headers
|
|
||||||
this.ensureAllDayContainers(calendarHeader);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -193,9 +193,6 @@ export class NavigationRenderer {
|
||||||
header.appendChild(headerElement);
|
header.appendChild(headerElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always ensure all-day containers exist for all days
|
|
||||||
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarConfig.getCalendarMode());
|
|
||||||
headerRenderer.ensureAllDayContainers(header as HTMLElement);
|
|
||||||
|
|
||||||
// Render day columns for target week
|
// Render day columns for target week
|
||||||
dates.forEach(date => {
|
dates.forEach(date => {
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ swp-header-cell,
|
||||||
swp-time-cell,
|
swp-time-cell,
|
||||||
swp-day-cell,
|
swp-day-cell,
|
||||||
swp-events-container,
|
swp-events-container,
|
||||||
swp-event,
|
swp-day-columns swp-event,
|
||||||
swp-loading-overlay,
|
swp-loading-overlay,
|
||||||
swp-nav-group,
|
swp-nav-group,
|
||||||
swp-nav-button,
|
swp-nav-button,
|
||||||
|
|
@ -117,7 +117,7 @@ swp-date-range,
|
||||||
swp-day-name,
|
swp-day-name,
|
||||||
swp-day-date,
|
swp-day-date,
|
||||||
swp-event-time,
|
swp-event-time,
|
||||||
swp-event-title,
|
swp-day-columns swp-event-title,
|
||||||
swp-spinner {
|
swp-spinner {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
@ -151,11 +151,11 @@ swp-spinner {
|
||||||
swp-calendar-container,
|
swp-calendar-container,
|
||||||
swp-calendar-grid,
|
swp-calendar-grid,
|
||||||
swp-day-column,
|
swp-day-column,
|
||||||
swp-event,
|
swp-day-columns swp-event,
|
||||||
swp-event-group,
|
swp-day-columns swp-event-group,
|
||||||
swp-time-axis,
|
swp-time-axis,
|
||||||
swp-event-title,
|
swp-day-columns swp-event-title,
|
||||||
swp-event-time {
|
swp-day-columns swp-event-time {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
-moz-user-select: none;
|
-moz-user-select: none;
|
||||||
|
|
@ -163,8 +163,8 @@ swp-event-time {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enable text selection for events when double-clicked */
|
/* Enable text selection for events when double-clicked */
|
||||||
swp-event.text-selectable swp-event-title,
|
swp-day-columns swp-event.text-selectable swp-day-columns swp-event-title,
|
||||||
swp-event.text-selectable swp-event-time {
|
swp-day-columns swp-event.text-selectable swp-day-columns swp-event-time {
|
||||||
user-select: text;
|
user-select: text;
|
||||||
-webkit-user-select: text;
|
-webkit-user-select: text;
|
||||||
-moz-user-select: text;
|
-moz-user-select: text;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/* styles/components/events.css */
|
/* styles/components/events.css */
|
||||||
|
|
||||||
/* Event base styles */
|
/* Event base styles */
|
||||||
swp-event {
|
swp-day-columns swp-event {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -53,20 +53,20 @@ swp-event {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-event:hover {
|
swp-day-columns swp-event:hover {
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
transform: translateX(2px);
|
transform: translateX(2px);
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-event-time {
|
swp-day-columns swp-event-time {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-event-title {
|
swp-day-columns swp-event-title {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
|
|
||||||
|
|
@ -461,7 +461,7 @@ swp-events-layer {
|
||||||
pointer-events: none; /* Allow clicks to pass through to day column */
|
pointer-events: none; /* Allow clicks to pass through to day column */
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-event {
|
swp-day-columns swp-event {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,345 +0,0 @@
|
||||||
/* Base CSS - Variables are defined in calendar-base-css.css */
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Custom elements default display */
|
|
||||||
swp-calendar,
|
|
||||||
swp-calendar-nav,
|
|
||||||
swp-calendar-container,
|
|
||||||
swp-time-axis,
|
|
||||||
swp-calendar-header,
|
|
||||||
swp-scrollable-content,
|
|
||||||
swp-time-grid,
|
|
||||||
swp-day-columns,
|
|
||||||
swp-day-column,
|
|
||||||
swp-events-layer,
|
|
||||||
swp-event,
|
|
||||||
swp-loading-overlay,
|
|
||||||
swp-grid-container,
|
|
||||||
swp-grid-lines {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main calendar container */
|
|
||||||
swp-calendar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
background: var(--color-background);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation bar */
|
|
||||||
swp-calendar-nav {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr auto auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--color-background);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-nav-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-nav-button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: var(--color-background);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 150ms ease;
|
|
||||||
min-width: 40px;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-nav-button:hover {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search container */
|
|
||||||
swp-search-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-search-icon {
|
|
||||||
position: absolute;
|
|
||||||
left: 12px;
|
|
||||||
pointer-events: none;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-search-icon svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-search-container input[type="search"] {
|
|
||||||
padding: 8px 36px 8px 36px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 20px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
width: 200px;
|
|
||||||
transition: all 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-search-container input[type="search"]::-webkit-search-cancel-button {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-search-container input[type="search"]:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
background: var(--color-background);
|
|
||||||
width: 250px;
|
|
||||||
box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-search-container input[type="search"]::placeholder {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-search-clear {
|
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: all 150ms ease;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-search-clear:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-search-clear svg {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-search-clear[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-view-button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-view-button:not(:last-child) {
|
|
||||||
border-right: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-view-button[data-active="true"] {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Week container for sliding */
|
|
||||||
swp-grid-container {
|
|
||||||
grid-column: 2;
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto 1fr;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
transition: transform 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-grid-container.slide-out-left {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-grid-container.slide-out-right {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-grid-container.slide-in-left {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-grid-container.slide-in-right {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Time axis */
|
|
||||||
swp-time-axis {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: 1;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-right: 1px solid var(--color-border);
|
|
||||||
position: sticky;
|
|
||||||
left: 0;
|
|
||||||
z-index: 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-day-date {
|
|
||||||
display: block;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-day-header[data-today="true"] swp-day-date {
|
|
||||||
color: var(--color-primary);
|
|
||||||
background: rgba(33, 150, 243, 0.1);
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 4px auto 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollable content */
|
|
||||||
swp-scrollable-content {
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Time grid */
|
|
||||||
swp-time-grid {
|
|
||||||
position: relative;
|
|
||||||
height: calc(12 * var(--hour-height));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
swp-events-layer {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Events */
|
|
||||||
swp-event {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: move;
|
|
||||||
transition: box-shadow 150ms ease, transform 150ms ease;
|
|
||||||
z-index: 10;
|
|
||||||
left: 1px;
|
|
||||||
right: 1px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-event[data-type="meeting"] {
|
|
||||||
background: var(--color-event-meeting);
|
|
||||||
border-left: 4px solid var(--color-event-meeting-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-event[data-type="meal"] {
|
|
||||||
background: var(--color-event-meal);
|
|
||||||
border-left: 4px solid var(--color-event-meal-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-event[data-type="work"] {
|
|
||||||
background: var(--color-event-work);
|
|
||||||
border-left: 4px solid var(--color-event-work-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-event:hover {
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
filter: brightness(0.95);
|
|
||||||
z-index: 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-event-time {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0.8;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-event-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading */
|
|
||||||
swp-loading-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-loading-overlay[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-spinner {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: 3px solid #f3f3f3;
|
|
||||||
border-top: 3px solid var(--color-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Week info styles */
|
|
||||||
swp-week-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-week-number {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-date-range {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-view-selector {
|
|
||||||
display: flex;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue