diff --git a/docs/V2-ARCHITECTURE.md b/docs/V2-ARCHITECTURE.md new file mode 100644 index 0000000..732a558 --- /dev/null +++ b/docs/V2-ARCHITECTURE.md @@ -0,0 +1,408 @@ +# Calendar V2 Architecture + +## Oversigt + +Calendar V2 er bygget med en event-driven arkitektur og et fleksibelt grouping-system der tillader forskellige kalendervisninger (dag, uge, ressource, team). + +--- + +## Event System + +### CoreEvents Katalog + +Alle events er defineret i `src/v2/constants/CoreEvents.ts`. + +#### Drag-Drop Events + +| Event | Payload | Emitter | Subscribers | +|-------|---------|---------|-------------| +| `event:drag-start` | `IDragStartPayload` | DragDropManager | EdgeScrollManager | +| `event:drag-move` | `IDragMovePayload` | DragDropManager | EventRenderer | +| `event:drag-end` | `IDragEndPayload` | DragDropManager | EdgeScrollManager | +| `event:drag-cancel` | `IDragCancelPayload` | DragDropManager | EdgeScrollManager | +| `event:drag-column-change` | `IDragColumnChangePayload` | DragDropManager | EventRenderer | + +#### Resize Events + +| Event | Payload | Emitter | Subscribers | +|-------|---------|---------|-------------| +| `event:resize-start` | `IResizeStartPayload` | ResizeManager | - | +| `event:resize-end` | `IResizeEndPayload` | ResizeManager | - | + +#### Edge Scroll Events + +| Event | Payload | Emitter | Subscribers | +|-------|---------|---------|-------------| +| `edge-scroll:tick` | `{ scrollDelta: number }` | EdgeScrollManager | DragDropManager | +| `edge-scroll:started` | `{}` | EdgeScrollManager | - | +| `edge-scroll:stopped` | `{}` | EdgeScrollManager | - | + +#### Lifecycle Events + +| Event | Purpose | +|-------|---------| +| `core:initialized` | DI container klar | +| `core:ready` | Kalender fuldt initialiseret | +| `core:destroyed` | Cleanup færdig | + +#### Data Events + +| Event | Purpose | +|-------|---------| +| `data:loading` | Data fetch startet | +| `data:loaded` | Data fetch færdig | +| `data:error` | Data fetch fejlet | +| `entity:saved` | Entity gemt i IndexedDB | +| `entity:deleted` | Entity slettet fra IndexedDB | + +#### View Events + +| Event | Purpose | +|-------|---------| +| `view:changed` | View/grouping ændret | +| `view:rendered` | View rendering færdig | +| `events:rendered` | Events renderet til DOM | +| `grid:rendered` | Time grid renderet | + +--- + +## Event Flows + +### Drag-Drop Flow + +``` +pointerdown på event + └── DragDropManager.handlePointerDown() + └── Gem mouseDownPosition, capture pointer + +pointermove (>5px) + └── DragDropManager.initializeDrag() + ├── Opret ghost clone (opacity 0.3) + ├── Marker original med .dragging + ├── EMIT: event:drag-start + │ └── EdgeScrollManager starter scrollTick loop + └── Start animateDrag() RAF loop + +pointermove (under drag) + ├── DragDropManager.updateDragTarget() + │ ├── Detect kolonneændring + │ │ └── EMIT: event:drag-column-change + │ │ └── EventRenderer flytter element til ny kolonne + │ └── Beregn targetY for smooth interpolation + │ + └── DragDropManager.animateDrag() (RAF) + ├── Interpoler currentY mod targetY (factor 0.3) + ├── Opdater element.style.top + └── EMIT: event:drag-move + └── EventRenderer.updateDragTimestamp() + └── Beregn snapped tid og opdater visning + +EdgeScrollManager.scrollTick() (parallel RAF) + ├── Beregn velocity baseret på museafstand til kanter + │ - Inner zone (0-50px): 640 px/sek + │ - Outer zone (50-100px): 140 px/sek + ├── Scroll viewport: scrollTop += scrollDelta + └── EMIT: edge-scroll:tick + └── DragDropManager kompenserer element position + +pointerup + └── DragDropManager.handlePointerUp() + ├── Snap currentY til grid (15-min intervaller) + ├── Fjern ghost element + ├── EMIT: event:drag-end + │ └── EdgeScrollManager stopper scroll + └── Persist ændringer til IndexedDB +``` + +--- + +## Grouping System + +### ViewConfig + +```typescript +interface ViewConfig { + templateId: string; // 'day' | 'simple' | 'resource' | 'team' + groupings: GroupingConfig[]; +} + +interface GroupingConfig { + type: string; // 'date' | 'resource' | 'team' + values: string[]; // IDs der skal vises +} +``` + +### Sådan bestemmer groupings strukturen + +1. **Kolonneantal**: Produkt af alle grouping dimensioner + - Eksempel: `5 datoer × 2 ressourcer = 10 kolonner` + +2. **Header layout**: CSS grid bruger `data-levels` til at stakke headers + - `data-levels="date"` → 1 header række + - `data-levels="resource date"` → 2 header rækker + - `data-levels="team resource date"` → 3 header rækker + +3. **Renderer selektion**: Grouping types matches til tilgængelige renderers + +### Konfigurationseksempler + +#### Simple Date View (3 dage, ingen ressourcer) + +```typescript +{ + templateId: 'simple', + groupings: [ + { type: 'date', values: ['2024-01-15', '2024-01-16', '2024-01-17'] } + ] +} +``` + +**Resultat:** 3 kolonner, 1 header række + +``` +┌─── Date1 ───┬─── Date2 ───┬─── Date3 ───┐ +│ │ │ │ +``` + +#### Resource View (2 ressourcer, 3 dage) + +```typescript +{ + templateId: 'resource', + groupings: [ + { type: 'resource', values: ['EMP001', 'EMP002'] }, + { type: 'date', values: ['2024-01-15', '2024-01-16', '2024-01-17'] } + ] +} +``` + +**Resultat:** 6 kolonner, 2 header rækker + +``` +┌───── Resource1 (span 3) ─────┬───── Resource2 (span 3) ─────┐ +├─── D1 ───┬─── D2 ───┬─── D3 ─┼─── D1 ───┬─── D2 ───┬─── D3 ─┤ +│ │ │ │ │ │ │ +``` + +#### Team View (2 teams, 2 ressourcer, 3 dage) + +```typescript +{ + templateId: 'team', + groupings: [ + { type: 'team', values: ['team1', 'team2'] }, + { type: 'resource', values: ['res1', 'res2'] }, + { type: 'date', values: ['2024-01-15', '2024-01-16', '2024-01-17'] } + ] +} +``` + +**Resultat:** 12 kolonner, 3 header rækker + +``` +┌─────────── Team1 (span 6) ───────────┬─────────── Team2 (span 6) ───────────┐ +├───── Res1 (span 3) ──┬── Res2 (3) ───┼───── Res1 (span 3) ──┬── Res2 (3) ───┤ +├── D1 ─┬── D2 ─┬── D3 ┼── D1 ─┬── D2 ─┼── D1 ─┬── D2 ─┬── D3 ┼── D1 ─┬── D2 ─┤ +│ │ │ │ │ │ │ │ │ │ │ +``` + +--- + +## Header Rendering + +### data-levels Attribut + +`swp-calendar-header` modtager `data-levels` som bestemmer header row layout: + +```typescript +// I CalendarOrchestrator.render() +const levels = viewConfig.groupings.map(g => g.type).join(' '); +headerContainer.dataset.levels = levels; // "resource date" eller "team resource date" +``` + +### CSS Grid Styling + +```css +swp-calendar-header[data-levels="date"] > swp-day-header { + grid-row: 1; +} + +swp-calendar-header[data-levels="resource date"] { + > swp-resource-header { grid-row: 1; } + > swp-day-header { grid-row: 2; } +} + +swp-calendar-header[data-levels="team resource date"] { + > swp-team-header { grid-row: 1; } + > swp-resource-header { grid-row: 2; } + > swp-day-header { grid-row: 3; } +} +``` + +### Rendering Pipeline + +1. Renderers eksekveres i den rækkefølge de står i `viewConfig.groupings` +2. Hver renderer APPENDER sine headers til `headerContainer` +3. CSS bruger `data-levels` + element type til at positionere i korrekt række + +--- + +## Renderers + +### DateRenderer + +```typescript +class DateRenderer implements IRenderer { + readonly type = 'date'; + + render(context: IRenderContext): void { + // For HVER ressource (eller én gang hvis ingen): + // For HVER dato i filter['date']: + // Opret swp-day-header + swp-day-column + // Sæt dataset.date & dataset.resourceId + } +} +``` + +### ResourceRenderer + +```typescript +class ResourceRenderer implements IRenderer { + readonly type = 'resource'; + + async render(context: IRenderContext): Promise { + // Load IResource[] fra ResourceService + // For HVER ressource: + // Opret swp-resource-header + // Sæt grid-column: span ${dateCount} + } +} +``` + +### TeamRenderer + +```typescript +class TeamRenderer implements IRenderer { + readonly type = 'team'; + + render(context: IRenderContext): void { + // For HVERT team: + // Tæl ressourcer der tilhører team + // Opret swp-team-header + // Sæt grid-column: span ${colspan} + } +} +``` + +--- + +## Grid Konfiguration + +### IGridConfig + +```typescript +interface IGridConfig { + hourHeight: number; // pixels per time (fx 60) + dayStartHour: number; // fx 6 (06:00) + dayEndHour: number; // fx 18 (18:00) + snapInterval: number; // minutter (fx 15) +} +``` + +### Position Beregning + +```typescript +function calculateEventPosition(start: Date, end: Date, config: IGridConfig): EventPosition { + const startMinutes = start.getHours() * 60 + start.getMinutes(); + const endMinutes = end.getHours() * 60 + end.getMinutes(); + + const dayStartMinutes = config.dayStartHour * 60; + const minuteHeight = config.hourHeight / 60; + + return { + top: (startMinutes - dayStartMinutes) * minuteHeight, + height: (endMinutes - startMinutes) * minuteHeight + }; +} +``` + +**Eksempel:** Event 09:00-10:00 med dayStartHour=6, hourHeight=60 +- startMinutes = 540, dayStartMinutes = 360 +- top = (540 - 360) × 1 = **180px** +- height = (600 - 540) × 1 = **60px** + +--- + +## Z-Index Stack + +``` +z-index: 10 ← Events (interaktive, draggable) +z-index: 5 ← Unavailable zones (visuel, pointer-events: none) +z-index: 2 ← Time linjer (baggrund) +z-index: 1 ← Kvarter linjer (baggrund) +z-index: 0 ← Grid baggrund +``` + +--- + +## Komponenter + +| Komponent | Ansvar | Fil | +|-----------|--------|-----| +| **CalendarOrchestrator** | Orkestrerer renderer pipeline | `core/CalendarOrchestrator.ts` | +| **DateRenderer** | Opretter dag-headers og kolonner | `features/date/DateRenderer.ts` | +| **ResourceRenderer** | Opretter ressource-headers | `features/resource/ResourceRenderer.ts` | +| **TeamRenderer** | Opretter team-headers | `features/team/TeamRenderer.ts` | +| **EventRenderer** | Renderer events med positioner | `features/event/EventRenderer.ts` | +| **ScheduleRenderer** | Renderer unavailable zoner | `features/schedule/ScheduleRenderer.ts` | +| **DragDropManager** | Håndterer drag-drop | `managers/DragDropManager.ts` | +| **ResizeManager** | Håndterer event resize | `managers/ResizeManager.ts` | +| **EdgeScrollManager** | Auto-scroll ved kanter | `managers/EdgeScrollManager.ts` | +| **ScrollManager** | Synkroniserer scroll | `core/ScrollManager.ts` | +| **EventBus** | Central event dispatcher | `core/EventBus.ts` | +| **DateService** | Dato formatering og beregning | `core/DateService.ts` | + +--- + +## Filer + +``` +src/v2/ +├── constants/ +│ └── CoreEvents.ts # Alle event konstanter +├── core/ +│ ├── CalendarOrchestrator.ts +│ ├── DateService.ts +│ ├── EventBus.ts +│ ├── IGridConfig.ts +│ ├── IGroupingRenderer.ts +│ ├── RenderBuilder.ts +│ ├── ScrollManager.ts +│ └── ViewConfig.ts +├── features/ +│ ├── date/ +│ │ └── DateRenderer.ts +│ ├── event/ +│ │ └── EventRenderer.ts +│ ├── resource/ +│ │ └── ResourceRenderer.ts +│ ├── schedule/ +│ │ └── ScheduleRenderer.ts +│ └── team/ +│ └── TeamRenderer.ts +├── managers/ +│ ├── DragDropManager.ts +│ └── EdgeScrollManager.ts +├── storage/ +│ ├── BaseEntityService.ts +│ ├── IndexedDBContext.ts +│ └── events/ +│ ├── EventService.ts +│ └── EventStore.ts +├── types/ +│ ├── CalendarTypes.ts +│ ├── DragTypes.ts +│ └── ScheduleTypes.ts +└── utils/ + └── PositionUtils.ts +``` diff --git a/src/v2/V2CompositionRoot.ts b/src/v2/V2CompositionRoot.ts index f996ed6..3056a7d 100644 --- a/src/v2/V2CompositionRoot.ts +++ b/src/v2/V2CompositionRoot.ts @@ -44,6 +44,7 @@ import { DataSeeder } from './workers/DataSeeder'; // Features import { EventRenderer } from './features/event/EventRenderer'; import { ScheduleRenderer } from './features/schedule/ScheduleRenderer'; +import { HeaderDrawerRenderer } from './features/headerdrawer/HeaderDrawerRenderer'; // Schedule import { ScheduleOverrideStore } from './storage/schedules/ScheduleOverrideStore'; @@ -135,6 +136,7 @@ export function createV2Container(): Container { // Features builder.registerType(EventRenderer).as(); builder.registerType(ScheduleRenderer).as(); + builder.registerType(HeaderDrawerRenderer).as(); // Renderers - registreres som Renderer (array injection til CalendarOrchestrator) builder.registerType(DateRenderer).as(); diff --git a/src/v2/constants/CoreEvents.ts b/src/v2/constants/CoreEvents.ts index fbb93f0..917bdc0 100644 --- a/src/v2/constants/CoreEvents.ts +++ b/src/v2/constants/CoreEvents.ts @@ -37,6 +37,11 @@ export const CoreEvents = { EVENT_DRAG_CANCEL: 'event:drag-cancel', EVENT_DRAG_COLUMN_CHANGE: 'event:drag-column-change', + // Header drag (timed → header conversion) + EVENT_DRAG_ENTER_HEADER: 'event:drag-enter-header', + EVENT_DRAG_MOVE_HEADER: 'event:drag-move-header', + EVENT_DRAG_LEAVE_HEADER: 'event:drag-leave-header', + // Event resize EVENT_RESIZE_START: 'event:resize-start', EVENT_RESIZE_END: 'event:resize-end', diff --git a/src/v2/demo/DemoApp.ts b/src/v2/demo/DemoApp.ts index 34e5f35..c970835 100644 --- a/src/v2/demo/DemoApp.ts +++ b/src/v2/demo/DemoApp.ts @@ -10,6 +10,7 @@ import { ViewConfig } from '../core/ViewConfig'; import { DragDropManager } from '../managers/DragDropManager'; import { EdgeScrollManager } from '../managers/EdgeScrollManager'; import { ResizeManager } from '../managers/ResizeManager'; +import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer'; export class DemoApp { private animator!: NavigationAnimator; @@ -27,7 +28,8 @@ export class DemoApp { private dataSeeder: DataSeeder, private dragDropManager: DragDropManager, private edgeScrollManager: EdgeScrollManager, - private resizeManager: ResizeManager + private resizeManager: ResizeManager, + private headerDrawerRenderer: HeaderDrawerRenderer ) {} async init(): Promise { diff --git a/src/v2/features/headerdrawer/HeaderDrawerLayoutEngine.ts b/src/v2/features/headerdrawer/HeaderDrawerLayoutEngine.ts new file mode 100644 index 0000000..b407a58 --- /dev/null +++ b/src/v2/features/headerdrawer/HeaderDrawerLayoutEngine.ts @@ -0,0 +1,135 @@ +/** + * HeaderDrawerLayoutEngine - Calculates row placement for header items + * + * Prevents visual overlap by assigning items to different rows when + * they occupy the same columns. Uses a track-based algorithm similar + * to V1's AllDayLayoutEngine. + * + * Each row can hold multiple items as long as they don't overlap in columns. + * When an item spans columns that are already occupied, it's placed in the + * next available row. + */ + +export interface IHeaderItemLayout { + itemId: string; + gridArea: string; // "row / col-start / row+1 / col-end" + startColumn: number; + endColumn: number; + row: number; +} + +export interface IHeaderItemInput { + id: string; + columnStart: number; // 0-based column index + columnEnd: number; // 0-based end column (inclusive) +} + +export class HeaderDrawerLayoutEngine { + private tracks: boolean[][] = []; + private columnCount: number; + + constructor(columnCount: number) { + this.columnCount = columnCount; + this.reset(); + } + + /** + * Reset tracks for new layout calculation + */ + reset(): void { + this.tracks = [new Array(this.columnCount).fill(false)]; + } + + /** + * Calculate layout for all items + * Items should be sorted by start column for optimal packing + */ + calculateLayout(items: IHeaderItemInput[]): IHeaderItemLayout[] { + this.reset(); + const layouts: IHeaderItemLayout[] = []; + + for (const item of items) { + const row = this.findAvailableRow(item.columnStart, item.columnEnd); + + // Mark columns as occupied in this row + for (let col = item.columnStart; col <= item.columnEnd; col++) { + this.tracks[row][col] = true; + } + + // gridArea format: "row / col-start / row+1 / col-end" + // CSS grid uses 1-based indices + layouts.push({ + itemId: item.id, + gridArea: `${row + 1} / ${item.columnStart + 1} / ${row + 2} / ${item.columnEnd + 2}`, + startColumn: item.columnStart, + endColumn: item.columnEnd, + row: row + 1 // 1-based for CSS + }); + } + + return layouts; + } + + /** + * Calculate layout for a single new item + * Useful for real-time drag operations + */ + calculateSingleLayout(item: IHeaderItemInput): IHeaderItemLayout { + const row = this.findAvailableRow(item.columnStart, item.columnEnd); + + // Mark columns as occupied + for (let col = item.columnStart; col <= item.columnEnd; col++) { + this.tracks[row][col] = true; + } + + return { + itemId: item.id, + gridArea: `${row + 1} / ${item.columnStart + 1} / ${row + 2} / ${item.columnEnd + 2}`, + startColumn: item.columnStart, + endColumn: item.columnEnd, + row: row + 1 + }; + } + + /** + * Find the first row where all columns in range are available + */ + private findAvailableRow(startCol: number, endCol: number): number { + for (let row = 0; row < this.tracks.length; row++) { + if (this.isRowAvailable(row, startCol, endCol)) { + return row; + } + } + + // Add new row if all existing rows are occupied + this.tracks.push(new Array(this.columnCount).fill(false)); + return this.tracks.length - 1; + } + + /** + * Check if columns in range are all available in given row + */ + private isRowAvailable(row: number, startCol: number, endCol: number): boolean { + for (let col = startCol; col <= endCol; col++) { + if (this.tracks[row][col]) { + return false; + } + } + return true; + } + + /** + * Get the number of rows currently in use + */ + getRowCount(): number { + return this.tracks.length; + } + + /** + * Update column count (e.g., when view changes) + */ + setColumnCount(count: number): void { + this.columnCount = count; + this.reset(); + } +} diff --git a/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts b/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts new file mode 100644 index 0000000..0a0e680 --- /dev/null +++ b/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts @@ -0,0 +1,151 @@ +import { IEventBus } from '../../types/CalendarTypes'; +import { IGridConfig } from '../../core/IGridConfig'; +import { CoreEvents } from '../../constants/CoreEvents'; +import { + IDragEnterHeaderPayload, + IDragMoveHeaderPayload, + IDragLeaveHeaderPayload +} from '../../types/DragTypes'; + +/** + * HeaderDrawerRenderer - Handles rendering of items in the header drawer + * + * Listens to drag events from DragDropManager and creates/manages + * swp-header-item elements in the header drawer. + * + * Uses subgrid for column alignment with parent swp-calendar-header. + * Position items via gridArea for explicit row/column placement. + */ +export class HeaderDrawerRenderer { + private currentItem: HTMLElement | null = null; + private container: HTMLElement | null = null; + private sourceElement: HTMLElement | null = null; + + constructor( + private eventBus: IEventBus, + private gridConfig: IGridConfig + ) { + this.setupListeners(); + } + + /** + * Setup event listeners for drag events + */ + private setupListeners(): void { + this.eventBus.on(CoreEvents.EVENT_DRAG_ENTER_HEADER, (e) => { + const payload = (e as CustomEvent).detail; + this.handleDragEnter(payload); + }); + + this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE_HEADER, (e) => { + const payload = (e as CustomEvent).detail; + this.handleDragMove(payload); + }); + + this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => { + const payload = (e as CustomEvent).detail; + this.handleDragLeave(payload); + }); + + this.eventBus.on(CoreEvents.EVENT_DRAG_END, () => { + this.handleDragEnd(); + }); + + this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => { + this.cleanup(); + }); + } + + /** + * Handle drag entering header zone - create preview item + */ + private handleDragEnter(payload: IDragEnterHeaderPayload): void { + this.container = document.querySelector('swp-header-drawer'); + if (!this.container) return; + + // Store reference to source element + this.sourceElement = payload.element; + + // Create header item + const item = document.createElement('swp-header-item'); + item.dataset.id = payload.eventId; + item.dataset.itemType = payload.itemType; + item.dataset.date = payload.sourceDate; + item.dataset.duration = String(payload.duration); + item.textContent = payload.title; + + // Apply color class if present + if (payload.colorClass) { + item.classList.add(payload.colorClass); + } + + // Add dragging state + item.classList.add('dragging'); + + // Initial placement (duration determines column span) + // gridArea format: "row / col-start / row+1 / col-end" + const col = payload.sourceColumnIndex + 1; + const endCol = col + payload.duration; + item.style.gridArea = `1 / ${col} / 2 / ${endCol}`; + + this.container.appendChild(item); + this.currentItem = item; + + // Hide original element while in header + payload.element.style.visibility = 'hidden'; + } + + /** + * Handle drag moving within header - update column position + */ + private handleDragMove(payload: IDragMoveHeaderPayload): void { + if (!this.currentItem) return; + + // Update column position (duration=1 for now) + const col = payload.columnIndex + 1; + const duration = parseInt(this.currentItem.dataset.duration || '1', 10); + const endCol = col + duration; + + this.currentItem.style.gridArea = `1 / ${col} / 2 / ${endCol}`; + this.currentItem.dataset.date = payload.dateKey; + } + + /** + * Handle drag leaving header - remove preview and restore source + */ + private handleDragLeave(_payload: IDragLeaveHeaderPayload): void { + this.cleanup(); + } + + /** + * Handle drag end - finalize the item (it stays in header) + */ + private handleDragEnd(): void { + if (!this.currentItem) return; + + // Remove dragging state + this.currentItem.classList.remove('dragging'); + + // Item stays - it's now permanent + // TODO: Emit event to persist allDay=true change + + // Clear references but leave item in DOM + this.currentItem = null; + this.sourceElement = null; + } + + /** + * Cleanup preview item and restore source visibility + */ + private cleanup(): void { + // Remove preview item + this.currentItem?.remove(); + this.currentItem = null; + + // Restore source element visibility + if (this.sourceElement) { + this.sourceElement.style.visibility = ''; + this.sourceElement = null; + } + } +} diff --git a/src/v2/features/headerdrawer/index.ts b/src/v2/features/headerdrawer/index.ts new file mode 100644 index 0000000..7b19757 --- /dev/null +++ b/src/v2/features/headerdrawer/index.ts @@ -0,0 +1,2 @@ +export { HeaderDrawerRenderer } from './HeaderDrawerRenderer'; +export { HeaderDrawerLayoutEngine, type IHeaderItemLayout, type IHeaderItemInput } from './HeaderDrawerLayoutEngine'; diff --git a/src/v2/managers/DragDropManager.ts b/src/v2/managers/DragDropManager.ts index 639f0ad..3dab5f4 100644 --- a/src/v2/managers/DragDropManager.ts +++ b/src/v2/managers/DragDropManager.ts @@ -8,7 +8,10 @@ import { IDragMovePayload, IDragEndPayload, IDragCancelPayload, - IDragColumnChangePayload + IDragColumnChangePayload, + IDragEnterHeaderPayload, + IDragMoveHeaderPayload, + IDragLeaveHeaderPayload } from '../types/DragTypes'; interface DragState { @@ -39,6 +42,7 @@ export class DragDropManager { private pendingElement: HTMLElement | null = null; private pendingMouseOffset: IMousePosition | null = null; private container: HTMLElement | null = null; + private inHeader = false; private readonly DRAG_THRESHOLD = 5; private readonly INTERPOLATION_FACTOR = 0.3; @@ -159,6 +163,7 @@ export class DragDropManager { // Cleanup this.dragState.element.classList.remove('dragging'); this.dragState = null; + this.inHeader = false; }; private initializeDrag(element: HTMLElement, mouseOffset: IMousePosition, e: PointerEvent): void { @@ -217,6 +222,12 @@ export class DragDropManager { private updateDragTarget(e: PointerEvent): void { if (!this.dragState) return; + // Check header zone first + this.checkHeaderZone(e); + + // Skip normal grid handling if in header + if (this.inHeader) return; + // Check for column change const columnAtPoint = this.getColumnAtPoint(e.clientX); if (columnAtPoint && columnAtPoint !== this.dragState.currentColumn) { @@ -244,6 +255,74 @@ export class DragDropManager { } } + /** + * Check if pointer is in header zone and emit appropriate events + */ + private checkHeaderZone(e: PointerEvent): void { + if (!this.dragState) return; + + const headerViewport = document.querySelector('swp-header-viewport'); + if (!headerViewport) return; + + const rect = headerViewport.getBoundingClientRect(); + const isInHeader = e.clientY < rect.bottom; + + if (isInHeader && !this.inHeader) { + // Entered header + this.inHeader = true; + + const payload: IDragEnterHeaderPayload = { + eventId: this.dragState.eventId, + element: this.dragState.element, + sourceColumnIndex: this.getColumnIndex(this.dragState.columnElement), + sourceDate: this.dragState.columnElement.dataset.date || '', + title: this.dragState.element.querySelector('swp-event-title')?.textContent || '', + colorClass: [...this.dragState.element.classList].find(c => c.startsWith('is-')), + itemType: 'event', + duration: 1 + }; + + this.eventBus.emit(CoreEvents.EVENT_DRAG_ENTER_HEADER, payload); + } else if (!isInHeader && this.inHeader) { + // Left header + this.inHeader = false; + + const payload: IDragLeaveHeaderPayload = { + eventId: this.dragState.eventId + }; + + this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload); + } else if (isInHeader) { + // Moving within header + const column = this.getColumnAtX(e.clientX); + if (column) { + const payload: IDragMoveHeaderPayload = { + eventId: this.dragState.eventId, + columnIndex: this.getColumnIndex(column), + dateKey: column.dataset.date || '' + }; + + this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE_HEADER, payload); + } + } + } + + /** + * Get column index (0-based) for a column element + */ + private getColumnIndex(column: HTMLElement): number { + if (!this.container) return 0; + const columns = Array.from(this.container.querySelectorAll('swp-day-column')); + return columns.indexOf(column); + } + + /** + * Get column at X coordinate (alias for getColumnAtPoint) + */ + private getColumnAtX(clientX: number): HTMLElement | null { + return this.getColumnAtPoint(clientX); + } + /** * Find column element at given X coordinate */ @@ -323,5 +402,6 @@ export class DragDropManager { this.eventBus.emit(CoreEvents.EVENT_DRAG_CANCEL, payload); this.dragState = null; + this.inHeader = false; } } diff --git a/src/v2/types/DragTypes.ts b/src/v2/types/DragTypes.ts index ab611f3..f7a25f6 100644 --- a/src/v2/types/DragTypes.ts +++ b/src/v2/types/DragTypes.ts @@ -45,3 +45,25 @@ export interface IDragColumnChangePayload { newColumn: HTMLElement; currentY: number; } + +// Header drag payloads +export interface IDragEnterHeaderPayload { + eventId: string; + element: HTMLElement; // Original dragged element + sourceColumnIndex: number; + sourceDate: string; + title: string; + colorClass?: string; + itemType: 'event' | 'reminder'; + duration: number; // Antal dage (default 1) +} + +export interface IDragMoveHeaderPayload { + eventId: string; + columnIndex: number; + dateKey: string; +} + +export interface IDragLeaveHeaderPayload { + eventId: string; +} diff --git a/wwwroot/css/v2/calendar-v2-events.css b/wwwroot/css/v2/calendar-v2-events.css index dd4d46c..4527519 100644 --- a/wwwroot/css/v2/calendar-v2-events.css +++ b/wwwroot/css/v2/calendar-v2-events.css @@ -346,3 +346,38 @@ swp-allday-container swp-event.transitioning { .is-amber { --b-primary: var(--b-color-amber); } .is-orange { --b-primary: var(--b-color-orange); } .is-deep-orange { --b-primary: var(--b-color-deep-orange); } + +/* Header drawer items */ +swp-header-item { + --b-text: var(--color-text); + + /* Positioneres via style.gridArea */ + height: 22px; + margin: 2px 4px; + padding: 2px 8px; + border-radius: 3px; + font-size: 0.75rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + cursor: pointer; + user-select: none; + transition: background-color 200ms ease; + + /* Color system fra swp-event */ + background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix)); + color: var(--b-text); + border-left: 4px solid var(--b-primary); + + &:hover { + background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix)); + } + + /* Dragging state */ + &.dragging { + opacity: 0.7; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + } +} diff --git a/wwwroot/css/v2/calendar-v2-layout.css b/wwwroot/css/v2/calendar-v2-layout.css index 895d4f6..42e303e 100644 --- a/wwwroot/css/v2/calendar-v2-layout.css +++ b/wwwroot/css/v2/calendar-v2-layout.css @@ -73,8 +73,12 @@ swp-header-spacer { } swp-header-drawer { - display: block; - height: 0; + display: grid; + grid-template-columns: repeat(var(--grid-columns), minmax(var(--day-column-min-width), 1fr)); + min-width: calc(var(--grid-columns) * var(--day-column-min-width)); + grid-auto-rows: 28px; + gap: 2px 0; + min-height: 0; overflow: hidden; background: var(--color-background-alt); border-bottom: 1px solid var(--color-border);