Enables event resizing via drag handles

Adds a resize handle manager to handle mouse hover
effects on calendar events and display a resize indicator.

This allows users to visually identify and initiate
event resizing by hovering near the bottom edge of an event.
This commit is contained in:
Janus C. H. Knudsen 2025-10-06 23:38:01 +02:00
parent 7d7a8d9208
commit a9819a8bf1
4 changed files with 130 additions and 61 deletions

View file

@ -8,6 +8,7 @@ import { ViewManager } from '../managers/ViewManager';
import { CalendarManager } from '../managers/CalendarManager'; import { CalendarManager } from '../managers/CalendarManager';
import { DragDropManager } from '../managers/DragDropManager'; import { DragDropManager } from '../managers/DragDropManager';
import { AllDayManager } from '../managers/AllDayManager'; import { AllDayManager } from '../managers/AllDayManager';
import { ResizeHandleManager } from '../managers/ResizeHandleManager';
import { CalendarManagers } from '../types/ManagerTypes'; import { CalendarManagers } from '../types/ManagerTypes';
/** /**
@ -39,6 +40,7 @@ export class ManagerFactory {
const viewManager = new ViewManager(eventBus); const viewManager = new ViewManager(eventBus);
const dragDropManager = new DragDropManager(eventBus); const dragDropManager = new DragDropManager(eventBus);
const allDayManager = new AllDayManager(eventManager); const allDayManager = new AllDayManager(eventManager);
const resizeHandleManager = new ResizeHandleManager();
// CalendarManager depends on all other managers // CalendarManager depends on all other managers
const calendarManager = new CalendarManager( const calendarManager = new CalendarManager(
@ -59,7 +61,8 @@ export class ManagerFactory {
viewManager, viewManager,
calendarManager, calendarManager,
dragDropManager, dragDropManager,
allDayManager allDayManager,
resizeHandleManager
}; };
} }
@ -70,6 +73,7 @@ export class ManagerFactory {
try { try {
await managers.calendarManager.initialize?.(); await managers.calendarManager.initialize?.();
managers.resizeHandleManager.initialize();
} catch (error) { } catch (error) {
throw error; throw error;
} }

View file

@ -0,0 +1,89 @@
export class ResizeHandleManager {
private resizeZoneHeight =15; // Must match CSS ::after height
private lastHoveredEvent: HTMLElement | null = null;
public initialize(): void {
this.setupEventListeners();
}
private setupEventListeners(): void {
// Listen to global mouseover from document
document.addEventListener('mouseover', (e: MouseEvent) => {
this.handleGlobalMouseOver(e);
});
document.addEventListener('mousemove', (e: MouseEvent) => {
this.handleGlobalMouseMove(e);
});
document.addEventListener('mouseout', (e: MouseEvent) => {
this.handleGlobalMouseOut(e);
});
}
private handleGlobalMouseOver(e: MouseEvent): void {
const target = e.target as HTMLElement;
const eventElement = target.closest<HTMLElement>('swp-day-columns swp-event');
if (eventElement) {
this.lastHoveredEvent = eventElement;
}
}
private handleGlobalMouseMove(e: MouseEvent): void {
// Check all events to see if mouse is in their resize zone
const events = document.querySelectorAll<HTMLElement>('swp-day-columns swp-event');
events.forEach(eventElement => {
const rect = eventElement.getBoundingClientRect();
const mouseY = e.clientY;
const mouseX = e.clientX;
// Check if mouse is within element bounds horizontally
const isInHorizontalBounds = mouseX >= rect.left && mouseX <= rect.right;
// Check if mouse is in bottom 25px of the element
const distanceFromBottom = rect.bottom - mouseY;
const isInResizeZone = distanceFromBottom >= 0 && distanceFromBottom <= this.resizeZoneHeight;
if (isInHorizontalBounds && isInResizeZone) {
this.showResizeIndicator(eventElement);
console.log('✅ In resize zone - bottom 25px');
} else {
this.hideResizeIndicator(eventElement);
}
});
}
private showResizeIndicator(eventElement: HTMLElement): void {
// Check if indicator already exists
let indicator = eventElement.querySelector<HTMLElement>('swp-resize-indicator');
if (!indicator) {
indicator = document.createElement('swp-resize-indicator');
eventElement.appendChild(indicator);
}
eventElement.setAttribute('data-resize-hover', 'true');
}
private hideResizeIndicator(eventElement: HTMLElement): void {
const indicator = eventElement.querySelector<HTMLElement>('swp-resize-indicator');
if (indicator) {
indicator.remove();
}
eventElement.removeAttribute('data-resize-hover');
}
private handleGlobalMouseOut(e: MouseEvent): void {
const target = e.target as HTMLElement;
const eventElement = target.closest<HTMLElement>('swp-day-columns swp-event');
if (eventElement) {
// Don't remove immediately - let mousemove handle it
}
}
}

View file

@ -13,6 +13,7 @@ export interface CalendarManagers {
calendarManager: CalendarManager; calendarManager: CalendarManager;
dragDropManager: unknown; // Avoid interface conflicts dragDropManager: unknown; // Avoid interface conflicts
allDayManager: unknown; // Avoid interface conflicts allDayManager: unknown; // Avoid interface conflicts
resizeHandleManager: ResizeHandleManager;
} }
/** /**
@ -70,6 +71,10 @@ export interface AllDayManager extends IManager {
[key: string]: unknown; // Allow any properties from actual implementation [key: string]: unknown; // Allow any properties from actual implementation
} }
export interface ResizeHandleManager extends IManager {
// ResizeHandleManager handles hover effects for resize handles
}
export interface ResourceData { export interface ResourceData {
resources: Resource[]; resources: Resource[];
assignments?: ResourceAssignment[]; assignments?: ResourceAssignment[];

View file

@ -70,6 +70,35 @@ swp-day-columns swp-event:hover {
z-index: 20; z-index: 20;
} }
/* Resize handle indicator - created by JavaScript */
swp-resize-indicator {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 15px;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="4"><rect x="0" y="0" width="16" height="1" fill="%23666"/><rect x="0" y="3" width="16" height="1" fill="%23666"/></svg>');
background-position: center center;
background-repeat: no-repeat;
pointer-events: none;
z-index: 10;
opacity: 0;
animation: fadeIn 0.8s ease forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
swp-day-columns swp-event[data-resize-hover="true"] {
cursor: ns-resize;
}
swp-day-columns swp-event-time { swp-day-columns swp-event-time {
display: block; display: block;
font-size: 0.875rem; font-size: 0.875rem;
@ -83,64 +112,6 @@ swp-day-columns swp-event-title {
line-height: 1.3; line-height: 1.3;
} }
/* External resize handles */
swp-resize-handle {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 24px;
height: 4px;
opacity: 0;
transition: opacity var(--transition-fast);
cursor: ns-resize;
z-index: 30;
background: var(--color-primary);
border-radius: 3px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
/* Subtle grip pattern */
&::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 1px;
background: rgba(255, 255, 255, 0.6);
border-radius: 0.5px;
box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.3);
}
}
/* Top resize handle - positioned OUTSIDE event */
swp-resize-handle[data-position="top"] {
top: -6px;
}
/* Bottom resize handle - positioned OUTSIDE event */
swp-resize-handle[data-position="bottom"] {
bottom: -6px;
}
/* Resize handles controlled by JavaScript - no general hover */
swp-handle-hitarea {
position: absolute;
left: -8px;
right: -8px;
top: -6px;
bottom: -6px;
cursor: ns-resize;
}
swp-handle-hitarea[data-position="top"] {
top: 4px;
}
swp-handle-hitarea[data-position="bottom"] {
bottom: 4px;
}
/* Multi-day events */ /* Multi-day events */
swp-multi-day-event { swp-multi-day-event {
position: relative; position: relative;