Improves event resizing with smooth animation
Replaces the previous rough event resizing implementation with a smooth, animated approach. Uses pointer events for accurate tracking and adds a visual resize handle for better user interaction. Also refactors drag and drop to exclude resize handle.
This commit is contained in:
parent
ce0a9b19eb
commit
1e5b3166b2
4 changed files with 420 additions and 230 deletions
179
.workbench/anotherresize.txt
Normal file
179
.workbench/anotherresize.txt
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { eventBus } from '../core/EventBus';
|
||||||
|
import { CoreEvents } from '../constants/CoreEvents';
|
||||||
|
import { calendarConfig } from '../core/CalendarConfig';
|
||||||
|
|
||||||
|
type SwpEventEl = HTMLElement & { updateHeight?: (h: number) => void };
|
||||||
|
|
||||||
|
export class ResizeHandleManager {
|
||||||
|
private cachedEvents: SwpEventEl[] = [];
|
||||||
|
private isResizing = false;
|
||||||
|
private targetEl: SwpEventEl | null = null;
|
||||||
|
|
||||||
|
private startY = 0;
|
||||||
|
private startDurationMin = 0;
|
||||||
|
private direction: 'grow' | 'shrink' = 'grow';
|
||||||
|
|
||||||
|
private hourHeightPx: number;
|
||||||
|
private snapMin: number;
|
||||||
|
private minDurationMin: number;
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private currentHeight = 0;
|
||||||
|
private targetHeight = 0;
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
private unsubscribers: Array<() => void> = [];
|
||||||
|
private pointerCaptured = false;
|
||||||
|
private prevZ?: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const grid = calendarConfig.getGridSettings();
|
||||||
|
this.hourHeightPx = grid.hourHeight;
|
||||||
|
this.snapMin = grid.snapInterval;
|
||||||
|
this.minDurationMin = grid.minEventDuration ?? this.snapMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public initialize(): void {
|
||||||
|
this.refreshEventCache();
|
||||||
|
this.attachGlobalListeners();
|
||||||
|
this.attachHandles();
|
||||||
|
this.subToBus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
document.removeEventListener('pointerdown', this.onPointerDown, true);
|
||||||
|
document.removeEventListener('pointermove', this.onPointerMove, true);
|
||||||
|
document.removeEventListener('pointerup', this.onPointerUp, true);
|
||||||
|
this.unsubscribers.forEach(u => u());
|
||||||
|
}
|
||||||
|
|
||||||
|
private minutesPerPx(): number {
|
||||||
|
return 60 / this.hourHeightPx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pxFromMinutes(min: number): number {
|
||||||
|
return (min / 60) * this.hourHeightPx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private roundSnap(min: number, dir: 'grow' | 'shrink'): number {
|
||||||
|
const q = min / this.snapMin;
|
||||||
|
return (dir === 'grow' ? Math.ceil(q) : Math.floor(q)) * this.snapMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshEventCache(): void {
|
||||||
|
this.cachedEvents = Array.from(
|
||||||
|
document.querySelectorAll<SwpEventEl>('swp-day-columns swp-event')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachHandles(): void {
|
||||||
|
// ensure a single handle per event
|
||||||
|
this.cachedEvents.forEach(el => {
|
||||||
|
if (!el.querySelector(':scope > swp-resize-handle')) {
|
||||||
|
const handle = document.createElement('swp-resize-handle');
|
||||||
|
handle.setAttribute('aria-label', 'Resize event');
|
||||||
|
handle.setAttribute('role', 'separator');
|
||||||
|
el.appendChild(handle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachGlobalListeners(): void {
|
||||||
|
document.addEventListener('pointerdown', this.onPointerDown, true);
|
||||||
|
document.addEventListener('pointermove', this.onPointerMove, true);
|
||||||
|
document.addEventListener('pointerup', this.onPointerUp, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private subToBus(): void {
|
||||||
|
const sub = (ev: string, fn: () => void) => {
|
||||||
|
eventBus.on(ev, fn);
|
||||||
|
this.unsubscribers.push(() => eventBus.off(ev, fn));
|
||||||
|
};
|
||||||
|
const refresh = () => { this.refreshEventCache(); this.attachHandles(); };
|
||||||
|
[CoreEvents.GRID_RENDERED, CoreEvents.EVENTS_RENDERED,
|
||||||
|
CoreEvents.EVENT_CREATED, CoreEvents.EVENT_UPDATED,
|
||||||
|
CoreEvents.EVENT_DELETED].forEach(ev => sub(ev, refresh));
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerDown = (e: PointerEvent) => {
|
||||||
|
const handle = (e.target as HTMLElement).closest('swp-resize-handle');
|
||||||
|
if (!handle) return;
|
||||||
|
|
||||||
|
const el = handle.parentElement as SwpEventEl;
|
||||||
|
this.targetEl = el;
|
||||||
|
this.isResizing = true;
|
||||||
|
this.startY = e.clientY;
|
||||||
|
|
||||||
|
// udled start-varighed fra højde
|
||||||
|
const startHeight = el.offsetHeight;
|
||||||
|
this.startDurationMin = Math.max(
|
||||||
|
this.minDurationMin,
|
||||||
|
Math.round(startHeight * this.minutesPerPx())
|
||||||
|
);
|
||||||
|
|
||||||
|
this.prevZ = (el.closest<HTMLElement>('swp-event-group') ?? el).style.zIndex;
|
||||||
|
(el.closest<HTMLElement>('swp-event-group') ?? el).style.zIndex = '1000';
|
||||||
|
|
||||||
|
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||||||
|
this.pointerCaptured = true;
|
||||||
|
document.documentElement.classList.add('swp--resizing'); // fx user-select: none; cursor: ns-resize
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onPointerMove = (e: PointerEvent) => {
|
||||||
|
if (!this.isResizing || !this.targetEl) return;
|
||||||
|
|
||||||
|
const dy = e.clientY - this.startY;
|
||||||
|
this.direction = dy >= 0 ? 'grow' : 'shrink';
|
||||||
|
|
||||||
|
const deltaMin = dy * this.minutesPerPx();
|
||||||
|
const rawMin = this.startDurationMin + deltaMin;
|
||||||
|
const clamped = Math.max(this.minDurationMin, rawMin);
|
||||||
|
const snappedMin = this.roundSnap(clamped, this.direction);
|
||||||
|
|
||||||
|
this.targetHeight = this.pxFromMinutes(snappedMin);
|
||||||
|
|
||||||
|
if (this.animationId == null) {
|
||||||
|
this.currentHeight = this.targetEl.offsetHeight;
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private animate = () => {
|
||||||
|
if (!this.isResizing || !this.targetEl) { this.animationId = null; return; }
|
||||||
|
|
||||||
|
const diff = this.targetHeight - this.currentHeight;
|
||||||
|
if (Math.abs(diff) > 0.5) {
|
||||||
|
this.currentHeight += diff * 0.35;
|
||||||
|
this.targetEl.updateHeight?.(this.currentHeight);
|
||||||
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
|
} else {
|
||||||
|
this.currentHeight = this.targetHeight;
|
||||||
|
this.targetEl.updateHeight?.(this.currentHeight);
|
||||||
|
this.animationId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onPointerUp = (e: PointerEvent) => {
|
||||||
|
if (!this.isResizing || !this.targetEl) return;
|
||||||
|
|
||||||
|
if (this.animationId != null) cancelAnimationFrame(this.animationId);
|
||||||
|
this.animationId = null;
|
||||||
|
|
||||||
|
// sikker slut-snap
|
||||||
|
this.targetEl.updateHeight?.(this.targetHeight - 3); // lille gap til grid-linjer
|
||||||
|
|
||||||
|
const group = this.targetEl.closest<HTMLElement>('swp-event-group') ?? this.targetEl;
|
||||||
|
group.style.zIndex = this.prevZ ?? '';
|
||||||
|
this.prevZ = undefined;
|
||||||
|
|
||||||
|
this.isResizing = false;
|
||||||
|
this.targetEl = null;
|
||||||
|
|
||||||
|
if (this.pointerCaptured) {
|
||||||
|
try { (e.target as Element).releasePointerCapture?.(e.pointerId); } catch {}
|
||||||
|
this.pointerCaptured = false;
|
||||||
|
}
|
||||||
|
document.documentElement.classList.remove('swp--resizing');
|
||||||
|
this.refreshEventCache();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -180,18 +180,11 @@ export class DragDropManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Found an event - check if in resize zone first
|
// Found an event - check if clicking on resize handle first
|
||||||
if (eventElement) {
|
if (eventElement) {
|
||||||
// Check if click is in bottom resize zone
|
// Check if click is on resize handle
|
||||||
if (eventElement.tagName === 'SWP-EVENT') {
|
if (target.closest('swp-resize-handle')) {
|
||||||
const rect = eventElement.getBoundingClientRect();
|
return; // Exit early - this is a resize operation, let ResizeHandleManager handle it
|
||||||
const mouseY = event.clientY;
|
|
||||||
const distanceFromBottom = rect.bottom - mouseY;
|
|
||||||
const resizeZoneHeight = 15; // Match ResizeHandleManager
|
|
||||||
// If in resize zone, don't handle this - let ResizeHandleManager take over
|
|
||||||
if (distanceFromBottom >= 0 && distanceFromBottom <= resizeZoneHeight) {
|
|
||||||
return; // Exit early - this is a resize operation
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Normal drag - prepare for potential dragging
|
// Normal drag - prepare for potential dragging
|
||||||
this.draggedElement = eventElement;
|
this.draggedElement = eventElement;
|
||||||
|
|
|
||||||
|
|
@ -2,234 +2,248 @@ import { eventBus } from '../core/EventBus';
|
||||||
import { CoreEvents } from '../constants/CoreEvents';
|
import { CoreEvents } from '../constants/CoreEvents';
|
||||||
import { calendarConfig } from '../core/CalendarConfig';
|
import { calendarConfig } from '../core/CalendarConfig';
|
||||||
|
|
||||||
|
type SwpEventEl = HTMLElement & { updateHeight?: (h: number) => void };
|
||||||
|
|
||||||
export class ResizeHandleManager {
|
export class ResizeHandleManager {
|
||||||
private resizeZoneHeight = 15; // Must match CSS ::after height
|
private cachedEvents: SwpEventEl[] = [];
|
||||||
private cachedEvents: HTMLElement[] = [];
|
|
||||||
|
|
||||||
// Resize state
|
|
||||||
private isResizing = false;
|
private isResizing = false;
|
||||||
private resizingElement: HTMLElement | null = null;
|
private targetEl: SwpEventEl | null = null;
|
||||||
private initialHeight = 0;
|
|
||||||
private initialMouseY = 0;
|
// Resize zone tracking (like DragDropManager hover tracking)
|
||||||
private targetHeight = 0;
|
private isResizeZoneTrackingActive = false;
|
||||||
private currentHeight = 0;
|
private currentTrackedEvent: SwpEventEl | null = null;
|
||||||
private animationFrameId: number | null = null;
|
|
||||||
|
private startY = 0;
|
||||||
|
private startDurationMin = 0;
|
||||||
|
private direction: 'grow' | 'shrink' = 'grow';
|
||||||
|
|
||||||
// Snap configuration
|
|
||||||
private snapIntervalMinutes = 15;
|
|
||||||
private hourHeightPx: number;
|
private hourHeightPx: number;
|
||||||
|
private snapMin: number;
|
||||||
|
private minDurationMin: number;
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private currentHeight = 0;
|
||||||
|
private targetHeight = 0;
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
private unsubscribers: Array<() => void> = [];
|
||||||
|
private pointerCaptured = false;
|
||||||
|
private prevZ?: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const gridSettings = calendarConfig.getGridSettings();
|
const grid = calendarConfig.getGridSettings();
|
||||||
this.hourHeightPx = gridSettings.hourHeight;
|
this.hourHeightPx = grid.hourHeight;
|
||||||
this.snapIntervalMinutes = gridSettings.snapInterval;
|
this.snapMin = grid.snapInterval;
|
||||||
|
this.minDurationMin = grid.minEventDuration ?? this.snapMin;
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialize(): void {
|
public initialize(): void {
|
||||||
this.refreshEventCache();
|
this.refreshEventCache();
|
||||||
this.setupEventListeners();
|
this.attachHandles();
|
||||||
|
this.attachGlobalListeners();
|
||||||
|
this.subToBus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
document.removeEventListener('pointerdown', this.onPointerDown, true);
|
||||||
|
document.removeEventListener('pointermove', this.onPointerMove, true);
|
||||||
|
document.removeEventListener('pointerup', this.onPointerUp, true);
|
||||||
|
this.unsubscribers.forEach(u => u());
|
||||||
|
}
|
||||||
|
|
||||||
|
private minutesPerPx(): number {
|
||||||
|
return 60 / this.hourHeightPx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pxFromMinutes(min: number): number {
|
||||||
|
return (min / 60) * this.hourHeightPx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private roundSnap(min: number, dir: 'grow' | 'shrink'): number {
|
||||||
|
const q = min / this.snapMin;
|
||||||
|
return (dir === 'grow' ? Math.ceil(q) : Math.floor(q)) * this.snapMin;
|
||||||
}
|
}
|
||||||
|
|
||||||
private refreshEventCache(): void {
|
private refreshEventCache(): void {
|
||||||
this.cachedEvents = Array.from(
|
this.cachedEvents = Array.from(
|
||||||
document.querySelectorAll<HTMLElement>('swp-day-columns swp-event')
|
document.querySelectorAll<SwpEventEl>('swp-day-columns swp-event')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupEventListeners(): void {
|
private attachHandles(): void {
|
||||||
// Hover detection (only when not resizing and mouse button is up)
|
// ensure a single handle per event
|
||||||
document.addEventListener('mousemove', (e: MouseEvent) => {
|
this.cachedEvents.forEach(el => {
|
||||||
if (!this.isResizing) {
|
if (!el.querySelector(':scope > swp-resize-handle')) {
|
||||||
// Only check for resize zones when mouse button is up
|
const handle = document.createElement('swp-resize-handle');
|
||||||
if (e.buttons === 0) {
|
handle.setAttribute('aria-label', 'Resize event');
|
||||||
this.handleGlobalMouseMove(e);
|
handle.setAttribute('role', 'separator');
|
||||||
}
|
el.appendChild(handle);
|
||||||
} else {
|
|
||||||
this.handleMouseMove(e);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resize mouse handling
|
|
||||||
document.addEventListener('mousedown', (e: MouseEvent) => {
|
|
||||||
this.handleMouseDown(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('mouseup', (e: MouseEvent) => {
|
|
||||||
this.handleMouseUp(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cache refresh
|
|
||||||
eventBus.on(CoreEvents.GRID_RENDERED, () => this.refreshEventCache());
|
|
||||||
eventBus.on(CoreEvents.EVENTS_RENDERED, () => this.refreshEventCache());
|
|
||||||
eventBus.on(CoreEvents.EVENT_CREATED, () => this.refreshEventCache());
|
|
||||||
eventBus.on(CoreEvents.EVENT_UPDATED, () => this.refreshEventCache());
|
|
||||||
eventBus.on(CoreEvents.EVENT_DELETED, () => this.refreshEventCache());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleGlobalMouseMove(e: MouseEvent): void {
|
private attachGlobalListeners(): void {
|
||||||
// Check all cached events to see if mouse is in their resize zone
|
// Use same pattern as DragDropManager - mouseenter to activate tracking
|
||||||
const events = this.cachedEvents;
|
const calendarContainer = document.querySelector('swp-calendar-container');
|
||||||
|
|
||||||
events.forEach(eventElement => {
|
if (calendarContainer) {
|
||||||
// Skip the element we're currently resizing
|
calendarContainer.addEventListener('mouseenter', (e) => {
|
||||||
if (this.resizingElement === eventElement) {
|
const target = e.target as HTMLElement;
|
||||||
|
const eventElement = target.closest<SwpEventEl>('swp-event');
|
||||||
|
|
||||||
|
if (eventElement && !this.isResizing) {
|
||||||
|
this.isResizeZoneTrackingActive = true;
|
||||||
|
this.currentTrackedEvent = eventElement;
|
||||||
|
}
|
||||||
|
}, true); // Capture phase
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', this.onPointerDown, true);
|
||||||
|
document.addEventListener('pointermove', this.onPointerMove, true);
|
||||||
|
document.addEventListener('pointerup', this.onPointerUp, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private subToBus(): void {
|
||||||
|
const sub = (ev: string, fn: () => void) => {
|
||||||
|
eventBus.on(ev, fn);
|
||||||
|
this.unsubscribers.push(() => eventBus.off(ev, fn));
|
||||||
|
};
|
||||||
|
const refresh = () => { this.refreshEventCache(); this.attachHandles(); };
|
||||||
|
[CoreEvents.GRID_RENDERED, CoreEvents.EVENTS_RENDERED,
|
||||||
|
CoreEvents.EVENT_CREATED, CoreEvents.EVENT_UPDATED,
|
||||||
|
CoreEvents.EVENT_DELETED].forEach(ev => sub(ev, refresh));
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkResizeZone(e: PointerEvent): void {
|
||||||
|
if (!this.isResizeZoneTrackingActive || !this.currentTrackedEvent || this.isResizing) return;
|
||||||
|
|
||||||
|
const rect = this.currentTrackedEvent.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX;
|
||||||
|
const mouseY = e.clientY;
|
||||||
|
|
||||||
|
// Check if mouse is still within event bounds
|
||||||
|
const isInBounds = mouseX >= rect.left && mouseX <= rect.right &&
|
||||||
|
mouseY >= rect.top && mouseY <= rect.bottom;
|
||||||
|
|
||||||
|
if (!isInBounds) {
|
||||||
|
// Mouse left event - deactivate tracking
|
||||||
|
this.hideResizeIndicator(this.currentTrackedEvent);
|
||||||
|
this.isResizeZoneTrackingActive = false;
|
||||||
|
this.currentTrackedEvent = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = eventElement.getBoundingClientRect();
|
// Check if in resize zone (bottom 15px)
|
||||||
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 resize zone of the element
|
|
||||||
const distanceFromBottom = rect.bottom - mouseY;
|
const distanceFromBottom = rect.bottom - mouseY;
|
||||||
const isInResizeZone = distanceFromBottom >= 0 && distanceFromBottom <= this.resizeZoneHeight;
|
const isInResizeZone = distanceFromBottom >= 0 && distanceFromBottom <= 15;
|
||||||
|
|
||||||
if (isInHorizontalBounds && isInResizeZone) {
|
if (isInResizeZone) {
|
||||||
this.showResizeIndicator(eventElement);
|
this.showResizeIndicator(this.currentTrackedEvent);
|
||||||
console.log(`✅ In resize zone - bottom ${this.resizeZoneHeight}px`);
|
|
||||||
} else {
|
} else {
|
||||||
this.hideResizeIndicator(eventElement);
|
this.hideResizeIndicator(this.currentTrackedEvent);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private showResizeIndicator(eventElement: HTMLElement): void {
|
private showResizeIndicator(el: SwpEventEl): void {
|
||||||
// Check if indicator already exists
|
el.setAttribute('data-resize-hover', 'true');
|
||||||
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(el: SwpEventEl): void {
|
||||||
|
el.removeAttribute('data-resize-hover');
|
||||||
}
|
}
|
||||||
|
|
||||||
private hideResizeIndicator(eventElement: HTMLElement): void {
|
private onPointerDown = (e: PointerEvent) => {
|
||||||
const indicator = eventElement.querySelector<HTMLElement>('swp-resize-indicator');
|
const handle = (e.target as HTMLElement).closest('swp-resize-handle');
|
||||||
if (indicator) {
|
if (!handle) return;
|
||||||
indicator.remove();
|
|
||||||
}
|
|
||||||
eventElement.removeAttribute('data-resize-hover');
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMouseDown(e: MouseEvent): void {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
const eventElement = target.closest<HTMLElement>('swp-event[data-resize-hover="true"]');
|
|
||||||
|
|
||||||
if (!eventElement) return;
|
|
||||||
|
|
||||||
// Check if click is in bottom resize zone
|
|
||||||
const rect = eventElement.getBoundingClientRect();
|
|
||||||
const distanceFromBottom = rect.bottom - e.clientY;
|
|
||||||
|
|
||||||
if (distanceFromBottom >= 0 && distanceFromBottom <= this.resizeZoneHeight) {
|
|
||||||
// START RESIZE
|
|
||||||
e.stopPropagation(); // Prevent DragDropManager from handling
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
|
const el = handle.parentElement as SwpEventEl;
|
||||||
|
this.targetEl = el;
|
||||||
this.isResizing = true;
|
this.isResizing = true;
|
||||||
this.resizingElement = eventElement;
|
this.startY = e.clientY;
|
||||||
this.initialHeight = eventElement.offsetHeight;
|
|
||||||
this.initialMouseY = e.clientY;
|
|
||||||
|
|
||||||
// Set high z-index on event-group if exists, otherwise on event itself
|
// udled start-varighed fra højde
|
||||||
const eventGroup = eventElement.closest<HTMLElement>('swp-event-group');
|
const startHeight = el.offsetHeight;
|
||||||
if (eventGroup) {
|
this.startDurationMin = Math.max(
|
||||||
eventGroup.style.zIndex = '1000';
|
this.minDurationMin,
|
||||||
} else {
|
Math.round(startHeight * this.minutesPerPx())
|
||||||
eventElement.style.zIndex = '1000';
|
);
|
||||||
|
|
||||||
|
this.prevZ = (el.closest<HTMLElement>('swp-event-group') ?? el).style.zIndex;
|
||||||
|
(el.closest<HTMLElement>('swp-event-group') ?? el).style.zIndex = '1000';
|
||||||
|
|
||||||
|
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||||||
|
this.pointerCaptured = true;
|
||||||
|
document.documentElement.classList.add('swp--resizing');
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onPointerMove = (e: PointerEvent) => {
|
||||||
|
// Check resize zone if not resizing
|
||||||
|
if (!this.isResizing) {
|
||||||
|
this.checkResizeZone(e);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔄 Resize started', this.initialHeight);
|
// Continue with resize logic
|
||||||
}
|
if (!this.targetEl) return;
|
||||||
}
|
|
||||||
|
|
||||||
private handleMouseMove(e: MouseEvent): void {
|
const dy = e.clientY - this.startY;
|
||||||
if (!this.isResizing || !this.resizingElement) return;
|
this.direction = dy >= 0 ? 'grow' : 'shrink';
|
||||||
|
|
||||||
const deltaY = e.clientY - this.initialMouseY;
|
// Calculate raw height from pixel delta (no snapping - 100% smooth like drag & drop)
|
||||||
const rawHeight = this.initialHeight + deltaY;
|
const startHeight = this.pxFromMinutes(this.startDurationMin);
|
||||||
|
const rawHeight = startHeight + dy;
|
||||||
|
const minHeight = this.pxFromMinutes(this.minDurationMin);
|
||||||
|
|
||||||
// Apply minimum height
|
this.targetHeight = Math.max(minHeight, rawHeight); // Raw height, no snap
|
||||||
this.targetHeight = Math.max(30, rawHeight);
|
|
||||||
|
|
||||||
// Start animation loop if not already running
|
if (this.animationId == null) {
|
||||||
if (this.animationFrameId === null) {
|
this.currentHeight = this.targetEl.offsetHeight;
|
||||||
this.currentHeight = this.resizingElement.offsetHeight;
|
|
||||||
this.animate();
|
this.animate();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
private animate(): void {
|
private animate = () => {
|
||||||
if (!this.isResizing || !this.resizingElement) {
|
if (!this.isResizing || !this.targetEl) { this.animationId = null; return; }
|
||||||
this.animationFrameId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Smooth interpolation towards target
|
|
||||||
const diff = this.targetHeight - this.currentHeight;
|
const diff = this.targetHeight - this.currentHeight;
|
||||||
const step = diff * 0.3; // 30% of distance per frame
|
|
||||||
|
|
||||||
// Update if difference is significant
|
|
||||||
if (Math.abs(diff) > 0.5) {
|
if (Math.abs(diff) > 0.5) {
|
||||||
this.currentHeight += step;
|
this.currentHeight += diff * 0.35;
|
||||||
|
this.targetEl.updateHeight?.(this.currentHeight);
|
||||||
const swpEvent = this.resizingElement as any;
|
this.animationId = requestAnimationFrame(this.animate);
|
||||||
if (swpEvent.updateHeight) {
|
|
||||||
swpEvent.updateHeight(this.currentHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.animationFrameId = requestAnimationFrame(() => this.animate());
|
|
||||||
} else {
|
} else {
|
||||||
// Close enough - snap to target
|
|
||||||
this.currentHeight = this.targetHeight;
|
this.currentHeight = this.targetHeight;
|
||||||
const swpEvent = this.resizingElement as any;
|
this.targetEl.updateHeight?.(this.currentHeight);
|
||||||
if (swpEvent.updateHeight) {
|
this.animationId = null;
|
||||||
swpEvent.updateHeight(this.currentHeight);
|
|
||||||
}
|
|
||||||
this.animationFrameId = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private handleMouseUp(e: MouseEvent): void {
|
private onPointerUp = (e: PointerEvent) => {
|
||||||
if (!this.isResizing || !this.resizingElement) return;
|
if (!this.isResizing || !this.targetEl) return;
|
||||||
|
|
||||||
// Cancel animation
|
if (this.animationId != null) cancelAnimationFrame(this.animationId);
|
||||||
if (this.animationFrameId !== null) {
|
this.animationId = null;
|
||||||
cancelAnimationFrame(this.animationFrameId);
|
|
||||||
this.animationFrameId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snap to grid on mouse up
|
// Snap to grid on pointer up (like DragDropManager does on mouseUp)
|
||||||
const snapDistancePx = (this.snapIntervalMinutes / 60) * this.hourHeightPx;
|
const currentHeight = this.targetEl.offsetHeight;
|
||||||
const currentHeight = this.resizingElement.offsetHeight;
|
const snapDistancePx = this.pxFromMinutes(this.snapMin);
|
||||||
const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx;
|
const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx;
|
||||||
const finalHeight = Math.max(30, snappedHeight) - 3; //a little gap, so it doesn't cover the horizontal time lines
|
const minHeight = this.pxFromMinutes(this.minDurationMin);
|
||||||
|
const finalHeight = Math.max(minHeight, snappedHeight) - 3; // lille gap til grid-linjer
|
||||||
|
|
||||||
const swpEvent = this.resizingElement as any;
|
this.targetEl.updateHeight?.(finalHeight);
|
||||||
if (swpEvent.updateHeight) {
|
|
||||||
swpEvent.updateHeight(finalHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Resize ended', finalHeight);
|
const group = this.targetEl.closest<HTMLElement>('swp-event-group') ?? this.targetEl;
|
||||||
|
group.style.zIndex = this.prevZ ?? '';
|
||||||
|
this.prevZ = undefined;
|
||||||
|
|
||||||
// Clear z-index on event-group if exists, otherwise on event itself
|
|
||||||
const eventGroup = this.resizingElement.closest<HTMLElement>('swp-event-group');
|
|
||||||
if (eventGroup) {
|
|
||||||
eventGroup.style.zIndex = '';
|
|
||||||
} else {
|
|
||||||
this.resizingElement.style.zIndex = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup state
|
|
||||||
this.isResizing = false;
|
this.isResizing = false;
|
||||||
this.resizingElement = null;
|
this.targetEl = null;
|
||||||
|
|
||||||
// Refresh cache for future operations
|
if (this.pointerCaptured) {
|
||||||
this.refreshEventCache();
|
try { (e.target as Element).releasePointerCapture?.(e.pointerId); } catch {}
|
||||||
|
this.pointerCaptured = false;
|
||||||
}
|
}
|
||||||
|
document.documentElement.classList.remove('swp--resizing');
|
||||||
|
this.refreshEventCache();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,45 +92,39 @@ swp-day-columns swp-event:hover {
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Resize handle indicator - created by JavaScript */
|
/* Resize handle - actual draggable element */
|
||||||
swp-resize-indicator {
|
swp-resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -4px;
|
bottom: 0;
|
||||||
left: 50%;
|
left: 0;
|
||||||
transform: translateX(-50%);
|
right: 0;
|
||||||
width: 50px;
|
height: 15px;
|
||||||
height: 8px;
|
cursor: ns-resize;
|
||||||
/* background set by JavaScript based on event color */
|
z-index: 25;
|
||||||
border-radius: 4px;
|
display: flex;
|
||||||
z-index: 30;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: fadeIn 0.2s ease forwards;
|
transition: opacity 150ms ease;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grip lines on handle */
|
/* Show handle on hover */
|
||||||
swp-resize-indicator::before {
|
swp-day-columns swp-event:hover swp-resize-handle,
|
||||||
content: '';
|
swp-day-columns swp-event[data-resize-hover="true"] swp-resize-handle {
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 20px;
|
|
||||||
height: 2px;
|
|
||||||
background: rgba(255, 255, 255, 0.8);
|
|
||||||
border-radius: 1px;
|
|
||||||
box-shadow: 0 -2px 0 rgba(255, 255, 255, 0.8),
|
|
||||||
0 2px 0 rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Handle visual indicator (grip lines) */
|
||||||
|
swp-resize-handle::before {
|
||||||
|
content: '';
|
||||||
|
width: 30px;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow:
|
||||||
|
0 -2px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 2px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 4px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
swp-day-columns swp-event[data-resize-hover="true"] {
|
swp-day-columns swp-event[data-resize-hover="true"] {
|
||||||
|
|
@ -138,6 +132,16 @@ swp-day-columns swp-event[data-resize-hover="true"] {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global resizing state */
|
||||||
|
.swp--resizing {
|
||||||
|
user-select: none !important;
|
||||||
|
cursor: ns-resize !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swp--resizing * {
|
||||||
|
cursor: ns-resize !important;
|
||||||
|
}
|
||||||
|
|
||||||
swp-day-columns swp-event-time {
|
swp-day-columns swp-event-time {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue