From 76ccb1e33a4455eb152dc44afa2764d4d1cfb102 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 16 Oct 2025 16:41:24 +0200 Subject: [PATCH] feat(scheduleRightPanel): alternative approach for draggint tasks onto day panel --- .../schedule-day-panel.component.html | 9 +- .../schedule-day-panel.component.ts | 242 +++++++++--------- .../schedule-external-drag.service.ts | 15 ++ .../tasks/task-list/task-list.component.html | 2 + .../tasks/task-list/task-list.component.ts | 11 + 5 files changed, 149 insertions(+), 130 deletions(-) create mode 100644 src/app/features/schedule/schedule-week/schedule-external-drag.service.ts diff --git a/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.html b/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.html index e972710e53..7292856323 100644 --- a/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.html +++ b/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.html @@ -1,12 +1,7 @@
diff --git a/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts b/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts index 12676e099a..e861ca81b0 100644 --- a/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts +++ b/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts @@ -29,34 +29,38 @@ import { mapToScheduleDays } from '../map-schedule-data/map-to-schedule-days'; import { mapScheduleDaysToScheduleEvents } from '../map-schedule-data/map-schedule-days-to-schedule-events'; import { FH } from '../schedule.const'; import { calculateTimeFromYPosition } from '../schedule-utils'; -import { CdkDrag, CdkDragDrop, CdkDragEnter, CdkDropList } from '@angular/cdk/drag-drop'; -import { DropListService } from '../../../core-ui/drop-list/drop-list.service'; +import { DragDropRegistry } from '@angular/cdk/drag-drop'; import { PlannerActions } from '../../planner/store/planner.actions'; import { TaskWithSubTasks } from '../../tasks/task.model'; import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'; import { Log } from '../../../core/log'; +import { Subscription } from 'rxjs'; +import { ScheduleExternalDragService } from '../schedule-week/schedule-external-drag.service'; const DEFAULT_MIN_DURATION = 15 * 60 * 1000; @Component({ selector: 'schedule-day-panel', standalone: true, - imports: [ScheduleWeekComponent, CdkDropList], + imports: [ScheduleWeekComponent], styleUrl: './schedule-day-panel.component.scss', templateUrl: './schedule-day-panel.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy { @ViewChild('scheduleWeek', { read: ElementRef }) scheduleWeekRef!: ElementRef; - @ViewChild('dropList') dropList!: CdkDropList; + @ViewChild('dropZone', { read: ElementRef }) dropZoneRef!: ElementRef; private _store = inject(Store); private _dateService = inject(DateService); private _calendarIntegrationService = inject(CalendarIntegrationService); private _globalTrackingIntervalService = inject(GlobalTrackingIntervalService); - private _dropListService = inject(DropListService); + private _dragDropRegistry = inject(DragDropRegistry); + private _externalDragService = inject(ScheduleExternalDragService); private _cdr = inject(ChangeDetectorRef); private _ngZone = inject(NgZone); + private _pointerUpSubscription: Subscription | null = null; + private _activeExternalTask: TaskWithSubTasks | null = null; // Drag preview properties dragPreviewTime = signal(null); @@ -164,8 +168,10 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy { } ngAfterViewInit(): void { - // Register this component as a drop target - this._dropListService.registerDropList(this.dropList); + // Listen for global pointer releases while a drag is active so we can finalize drops. + this._pointerUpSubscription = this._dragDropRegistry.pointerUp.subscribe((event) => { + this._ngZone.run(() => this._handlePointerUp(event)); + }); // Initial scroll to current time after view initialization setTimeout(() => { @@ -174,62 +180,49 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy { } ngOnDestroy(): void { - // Unregister the drop list when component is destroyed - if (this.dropList) { - this._dropListService.unregisterDropList(this.dropList); + if (this._pointerUpSubscription) { + this._pointerUpSubscription.unsubscribe(); + this._pointerUpSubscription = null; } - // Ensure preview styling is cleaned up this._applySchedulePreviewStyling(false); - } - - onDragEnter(event: CdkDragEnter<{ day: string }>): void { - this._startScheduleMode(); - } - - onDragExit(): void { - this._stopScheduleMode(); + this._activeExternalTask = null; } onDragMove(event: MouseEvent | TouchEvent): void { - // If dragging continues and we re-enter without cdkDropListEntered firing, - // auto-enable schedule mode when a CDK drag preview exists and it's not a schedule-event drag - const dragPreview = document.querySelector('.cdk-drag-preview') as HTMLElement | null; - if (dragPreview) { - const isScheduleEventDrag = !!dragPreview.querySelector('schedule-event'); - if (!this.isDragging() && !isScheduleEventDrag) { - this._startScheduleMode(); + const activeTask = this._externalDragService.activeTask(); + if (!activeTask) { + if (this.isDragging()) { + this._stopScheduleMode(); } - } - if (!this.isDragging()) return; - - const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY; - - // Update position for the preview badge (still follow cursor for visibility) - - // Get the actual drag preview position to match what dropPoint will be - let previewY = clientY; - - if (dragPreview) { - const rect = dragPreview.getBoundingClientRect(); - // Use the top of the drag preview, which should match event.dropPoint - previewY = rect.top; + return; } - // Calculate time based on the drag preview position (matching dropPoint logic) + this._activeExternalTask = activeTask; + if (!this.isDragging()) { + this._startScheduleMode(); + } + + // Always use the top of the task element for time calculation + const dragPreview = document.querySelector('.cdk-drag-preview') as HTMLElement | null; + if (!dragPreview) { + return; + } + + const rect = dragPreview.getBoundingClientRect(); + // Use the top of the drag preview for consistent time calculation + const previewY = rect.top; + const timestamp = this._calculateTimeFromYPosition(previewY); this.lastCalculatedTimestamp = timestamp; if (timestamp) { - const date = new Date(timestamp); - const hours = date.getHours(); - const minutes = date.getMinutes(); - // TODO internationalization - const timeStr = `${hours.toString().padStart(2, '0')}:${minutes - .toString() - .padStart(2, '0')}`; + const timeStr = this._formatPreviewTime(timestamp); this.dragPreviewTime.set(timeStr); this._updatePreviewTimeBadge(timeStr); + } else { + this.dragPreviewTime.set(null); + this._updatePreviewTimeBadge(''); } } @@ -246,70 +239,6 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy { return calculateTimeFromYPosition(clientY, gridRect, targetDay); } - onTaskDrop(event: CdkDragDrop<{ day: string }, any, TaskWithSubTasks>): void { - // Check if this is a schedule-event being dropped (internal drag) - const dragElement = event.item.element.nativeElement; - if (dragElement && dragElement.tagName.toLowerCase() === 'schedule-event') { - // This is a schedule-event drop, let the schedule-week component handle it - this.isDragging.set(false); - this.dragPreviewTime.set(null); - this.lastCalculatedTimestamp = null; - return; - } - - // Calculate drop time BEFORE clearing drag state (while preview element still exists) - const dropTime = this._calculateDropTime(); - - // Clear drag state after calculation - this._stopScheduleMode(); - - // Don't handle internal moves within the schedule - if (event.previousContainer === event.container) { - return; - } - - const task = event.item.data; - const targetDay = this.daysToShow()[0]; - - if (task && targetDay) { - if (dropTime !== null) { - // No shift: Schedule the task at the specific time (new default behavior) - this._store.dispatch( - TaskSharedActions.scheduleTaskWithTime({ - task: task, - dueWithTime: dropTime, - isMoveToBacklog: false, - }), - ); - // Auto-assign a default duration if none is set - if (!task.timeEstimate || task.timeEstimate <= 0) { - this._store.dispatch( - TaskSharedActions.updateTask({ - task: { id: task.id, changes: { timeEstimate: DEFAULT_MIN_DURATION } }, - }), - ); - } - } else { - // Fallback to day-level scheduling if time calculation fails - this._store.dispatch( - PlannerActions.planTaskForDay({ - task: task, - day: targetDay, - isAddToTop: true, - }), - ); - } - } - } - - // Prevent schedule-week event drags from entering this drop list - dropListEnterPredicate = (drag: CdkDrag): boolean => { - const el = drag.element?.nativeElement as HTMLElement | null; - // Deny if the dragged element is a schedule-event (handled internally by schedule-week) - const isScheduleEvent = !!el && el.tagName.toLowerCase() === 'schedule-event'; - return !isScheduleEvent; - }; - private _calculateDropTime(): number | null { // Use the exact timestamp that was calculated during the last drag move // This ensures perfect consistency between preview and actual scheduling @@ -328,6 +257,80 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy { return timestamp; } + private _handlePointerUp(event: MouseEvent | TouchEvent): void { + if (!this.isDragging()) { + return; + } + + // Treat any pointer release while the preview is active as a potential drop on the panel. + const task = this._activeExternalTask ?? this._externalDragService.activeTask(); + const pointer = this._getPointerPosition(event); + const isInside = pointer ? this._isPointWithinDropZone(pointer.x, pointer.y) : false; + const dropTime = isInside ? this._calculateDropTime() : null; + const targetDay = this.daysToShow()[0]; + + this._stopScheduleMode(); + this._externalDragService.setActiveTask(null); + + if (!task || !targetDay) { + return; + } + + if (dropTime !== null) { + this._store.dispatch( + TaskSharedActions.scheduleTaskWithTime({ + task, + dueWithTime: dropTime, + isMoveToBacklog: false, + }), + ); + if (!task.timeEstimate || task.timeEstimate <= 0) { + this._store.dispatch( + TaskSharedActions.updateTask({ + task: { id: task.id, changes: { timeEstimate: DEFAULT_MIN_DURATION } }, + }), + ); + } + } else { + this._store.dispatch( + PlannerActions.planTaskForDay({ + task, + day: targetDay, + isAddToTop: true, + }), + ); + } + } + + private _getPointerPosition( + event: MouseEvent | TouchEvent, + ): { x: number; y: number } | null { + if ('touches' in event) { + const touch = event.touches[0] ?? event.changedTouches?.[0]; + if (!touch) { + return null; + } + return { x: touch.clientX, y: touch.clientY }; + } + return { x: event.clientX, y: event.clientY }; + } + + private _isPointWithinDropZone(x: number, y: number): boolean { + const dropZoneEl = this.dropZoneRef?.nativeElement; + if (!dropZoneEl) { + return false; + } + const rect = dropZoneEl.getBoundingClientRect(); + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + } + + private _formatPreviewTime(timestamp: number): string { + const date = new Date(timestamp); + const hours = date.getHours(); + const minutes = date.getMinutes(); + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + } + private _scrollToCurrentTime(): void { if (!this.scheduleWeekRef?.nativeElement) { Log.warn('[ScheduleDayPanel] No scheduleWeekRef available'); @@ -406,6 +409,7 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy { this.isDragging.set(false); this.dragPreviewTime.set(null); this.lastCalculatedTimestamp = null; + this._activeExternalTask = null; this._applySchedulePreviewStyling(false); this._cdr.markForCheck(); document.removeEventListener('mousemove', this._onGlobalPointerMove); @@ -414,19 +418,11 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy { private _onGlobalPointerMove = (ev: MouseEvent | TouchEvent): void => { // If pointer is not over the side panel/drop zone anymore, leave schedule mode - const clientX = 'touches' in ev ? ev.touches[0].clientX : ev.clientX; - const clientY = 'touches' in ev ? ev.touches[0].clientY : ev.clientY; - const dropZoneEl = this.dropList?.element?.nativeElement as HTMLElement | null; - if (!dropZoneEl) { + const pointer = this._getPointerPosition(ev); + if (!pointer) { return; } - const rect = dropZoneEl.getBoundingClientRect(); - const isInside = - clientX >= rect.left && - clientX <= rect.right && - clientY >= rect.top && - clientY <= rect.bottom; - if (!isInside) { + if (!this._isPointWithinDropZone(pointer.x, pointer.y)) { // Re-enter Angular zone to ensure CD runs for OnPush this._ngZone.run(() => this._stopScheduleMode()); } diff --git a/src/app/features/schedule/schedule-week/schedule-external-drag.service.ts b/src/app/features/schedule/schedule-week/schedule-external-drag.service.ts new file mode 100644 index 0000000000..a8312c1625 --- /dev/null +++ b/src/app/features/schedule/schedule-week/schedule-external-drag.service.ts @@ -0,0 +1,15 @@ +import { Injectable, signal } from '@angular/core'; +import { TaskWithSubTasks } from '../../tasks/task.model'; + +@Injectable({ providedIn: 'root' }) +export class ScheduleExternalDragService { + private readonly _activeTask = signal(null); + + activeTask(): TaskWithSubTasks | null { + return this._activeTask(); + } + + setActiveTask(task: TaskWithSubTasks | null): void { + this._activeTask.set(task); + } +} diff --git a/src/app/features/tasks/task-list/task-list.component.html b/src/app/features/tasks/task-list/task-list.component.html index eacdd8711b..81a6747113 100644 --- a/src/app/features/tasks/task-list/task-list.component.html +++ b/src/app/features/tasks/task-list/task-list.component.html @@ -41,6 +41,8 @@ cdkDrag [cdkDragDisabled]="listModelId() === 'LATER_TODAY'" [cdkDragData]="task" + (cdkDragStarted)="onDragStarted(task)" + (cdkDragEnded)="onDragEnded()" > } diff --git a/src/app/features/tasks/task-list/task-list.component.ts b/src/app/features/tasks/task-list/task-list.component.ts index 736332d158..5eb4e7d865 100644 --- a/src/app/features/tasks/task-list/task-list.component.ts +++ b/src/app/features/tasks/task-list/task-list.component.ts @@ -38,6 +38,7 @@ import { TaskComponent } from '../task/task.component'; import { AsyncPipe } from '@angular/common'; import { TaskViewCustomizerService } from '../../task-view-customizer/task-view-customizer.service'; import { TaskLog } from '../../../core/log'; +import { ScheduleExternalDragService } from '../../schedule/schedule-week/schedule-external-drag.service'; export type TaskListId = 'PARENT' | 'SUB'; export type ListModelId = DropListModelSource | string; @@ -70,6 +71,7 @@ export class TaskListComponent implements OnDestroy, AfterViewInit { private _store = inject(Store); private _issueService = inject(IssueService); private _taskViewCustomizerService = inject(TaskViewCustomizerService); + private _scheduleExternalDragService = inject(ScheduleExternalDragService); dropListService = inject(DropListService); tasks = input([]); @@ -120,12 +122,21 @@ export class TaskListComponent implements OnDestroy, AfterViewInit { ngOnDestroy(): void { this.dropListService.unregisterDropList(this.dropList()!); + this._scheduleExternalDragService.setActiveTask(null); } trackByFn(i: number, task: Task): string { return task.id; } + onDragStarted(task: TaskWithSubTasks): void { + this._scheduleExternalDragService.setActiveTask(task); + } + + onDragEnded(): void { + this._scheduleExternalDragService.setActiveTask(null); + } + enterPredicate(drag: CdkDrag, drop: CdkDropList): boolean { // TODO this gets called very often for nested lists. Maybe there are possibilities to optimize const task = drag.data;