Adds header drawer and event drag interactions

Introduces HeaderDrawerRenderer and HeaderDrawerLayoutEngine to support dragging events into an all-day header drawer

Enables dynamic event placement and conversion between timed and all-day events through new drag interactions
Implements flexible layout calculation for header items with column and row management

Extends DragDropManager to handle header zone interactions
Adds new event types for header drag events
This commit is contained in:
Janus C. H. Knudsen 2025-12-10 23:11:11 +01:00
parent 026d83eb32
commit 6723658fd9
11 changed files with 850 additions and 4 deletions

View file

@ -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<EventRenderer>();
builder.registerType(ScheduleRenderer).as<ScheduleRenderer>();
builder.registerType(HeaderDrawerRenderer).as<HeaderDrawerRenderer>();
// Renderers - registreres som Renderer (array injection til CalendarOrchestrator)
builder.registerType(DateRenderer).as<IRenderer>();

View file

@ -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',

View file

@ -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<void> {

View file

@ -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();
}
}

View file

@ -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<IDragEnterHeaderPayload>).detail;
this.handleDragEnter(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_MOVE_HEADER, (e) => {
const payload = (e as CustomEvent<IDragMoveHeaderPayload>).detail;
this.handleDragMove(payload);
});
this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => {
const payload = (e as CustomEvent<IDragLeaveHeaderPayload>).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;
}
}
}

View file

@ -0,0 +1,2 @@
export { HeaderDrawerRenderer } from './HeaderDrawerRenderer';
export { HeaderDrawerLayoutEngine, type IHeaderItemLayout, type IHeaderItemInput } from './HeaderDrawerLayoutEngine';

View file

@ -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;
}
}

View file

@ -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;
}