UNPKG

@progress/kendo-angular-gantt

Version:
338 lines (337 loc) 16.7 kB
/**----------------------------------------------------------------------------------------- * Copyright © 2025 Progress Software Corporation. All rights reserved. * Licensed under commercial license. See LICENSE.md in the project root for more information *-------------------------------------------------------------------------------------------*/ import { Directive, Input, NgZone, Renderer2 } from '@angular/core'; import { isDocumentAvailable } from '@progress/kendo-angular-common'; import { PopupService } from '@progress/kendo-angular-popup'; import { GanttComponent } from '../gantt.component'; import { MappingService } from '../common/mapping.service'; import { TimelineScrollService } from '../scrolling/timeline-scroll.service'; import { DragValidationTooltipComponent } from './drag-validation-tooltip.component'; import { clientToOffsetCoords, getElementClientCenterCoords } from '../dependencies/utils'; import { isPresent, isDependencyDragClue, getClosestTaskWrapper, getDependencyTypeFromTargetTasks, getClosestTaskIndex, sameTaskClues, isTaskWrapper, fitToRange } from '../utils'; import { elementFromPoint } from '../utils'; import * as i0 from "@angular/core"; import * as i1 from "../gantt.component"; import * as i2 from "../common/mapping.service"; import * as i3 from "@progress/kendo-angular-popup"; import * as i4 from "../scrolling/timeline-scroll.service"; const DRAG_CLUE_HOVER_CLASS = 'k-hover'; const USER_SELECT_NONE_CLASS = 'k-user-select-none'; const TASK_WRAPPER_DRAG_CLASS = 'k-origin'; const DEFAULT_POPUP_VERTICAL_MARGIN = 20; /** * Enables creating new dependencies by dragging. * * ```html * <kendo-gantt kendoGanttDependencyDragCreate></kendo-gantt> * ``` * * @remarks * Applied to: {@link GanttComponent} */ export class DependencyDragCreateDirective { gantt; zone; renderer; mapper; popupService; timelineScrollService; /** * Shows the validation tooltip during drag operations. * * @default true */ displayValidationTooltip = true; get container() { if (!isPresent(this.gantt.timeline) || !isPresent(this.gantt.timeline.timelineContent)) { return null; } return this.gantt.timeline.timelineContent.nativeElement; } get polyline() { if (!isPresent(this.gantt.timeline) || !isPresent(this.gantt.timeline.dependencyDragCreatePolyline)) { return null; } return this.gantt.timeline.dependencyDragCreatePolyline.nativeElement; } get popupContainer() { if (!isPresent(this.gantt.timeline) || !isPresent(this.gantt.timeline.dragPopupContainer)) { return null; } return this.gantt.timeline.dragPopupContainer; } /** * Points to the drag clue where the dragging started (the FROM task). * Used to attach/detach classes to the target element and its task wrapper parent element. */ fromTaskClue; /** * The drag start element coords will be the same through the enitre dragging session, so compute them on press and cache them. */ polylineStartCoords; /** * Stored during dragging to be consumed by the container scroll subscription. * The scroll event doesn't expose the current pointer position, so it has to be stored separately. */ currentPointerClientCoords; scrollListenerDisposer; dragPopup; dragSubscriptions; constructor(gantt, zone, renderer, mapper, popupService, timelineScrollService) { this.gantt = gantt; this.zone = zone; this.renderer = renderer; this.mapper = mapper; this.popupService = popupService; this.timelineScrollService = timelineScrollService; this.gantt.renderDependencyDragClues = true; } ngAfterViewInit() { this.subscribeDraggable(); this.addScrollListener(); } ngOnDestroy() { this.unsubscribeDraggable(); this.removeScrollListener(); this.fromTaskClue = null; this.cancelScroll(); this.closeDragPopup(); } subscribeDraggable() { this.dragSubscriptions = this.gantt.timeline.timelineContainerPress .subscribe(this.handlePress.bind(this)); this.dragSubscriptions.add(this.gantt.timeline.timelineContainerDrag .subscribe(this.handleDrag.bind(this))); this.dragSubscriptions.add(this.gantt.timeline.timelineContainerRelease .subscribe(this.handleRelease.bind(this))); } unsubscribeDraggable() { if (isPresent(this.dragSubscriptions)) { this.dragSubscriptions.unsubscribe(); this.dragSubscriptions = null; } } handlePress({ clientX, clientY }) { // using `originalEvent.target` is not reliable under mobile devices with the current implementation of the draggable, so use this instead const target = elementFromPoint(clientX, clientY); if (isDependencyDragClue(target)) { this.fromTaskClue = target; this.assignDragStartClasses(this.fromTaskClue); // use the center of the target clue as polyline starting point const dragClueCenterCoords = getElementClientCenterCoords(this.fromTaskClue); // the polyline uses `position: aboslute`, so translate the client coordinates to offset coordinates (`left` and `top` relative to the timeline container) this.polylineStartCoords = clientToOffsetCoords(dragClueCenterCoords.left, dragClueCenterCoords.top, this.container); } } handleDrag({ clientX, clientY }) { if (isPresent(this.fromTaskClue)) { // the polyline uses `position: aboslute`, so translate the client coordinates to offset coordinates (`left` and `top` relative to the timeline container) const pointerOffsetCoords = clientToOffsetCoords(clientX, clientY, this.container); // the start coords are calculated just once per drag session in handlePress // use the current drag coords as polyline end coords this.updatePolyline(this.polylineStartCoords, pointerOffsetCoords); this.currentPointerClientCoords = { left: clientX, top: clientY }; if (this.gantt.dragScrollSettings.enabled) { // use client coordinates for scroll trigger this.scrollPointIntoView(this.currentPointerClientCoords); } if (this.displayValidationTooltip) { this.updateDragPopup(pointerOffsetCoords); } } } handleRelease({ clientX, clientY }) { if (!isPresent(this.fromTaskClue)) { return; } // using `originalEvent.target` is not reliable under mobile devices with the current implementation of the draggable, so use this instead const target = elementFromPoint(clientX, clientY); if (isDependencyDragClue(target) && !sameTaskClues(this.fromTaskClue, target, this.container)) { this.zone.run(() => { const fromTaskClue = this.fromTaskClue; const toTaskClue = target; const fromTask = this.gantt.renderedTreeListItems[getClosestTaskIndex(fromTaskClue, this.container)]; const toTask = this.gantt.renderedTreeListItems[getClosestTaskIndex(toTaskClue, this.container)]; const dependencyType = getDependencyTypeFromTargetTasks(fromTaskClue, toTaskClue); const { fromId, toId, type } = this.mapper.dependencyFields; this.gantt.dependencyAdd.emit({ fromTask: fromTask, toTask: toTask, type: dependencyType, isValid: this.gantt.validateNewDependency({ [fromId]: this.mapper.extractFromTask(fromTask, 'id'), [toId]: this.mapper.extractFromTask(toTask, 'id'), [type]: dependencyType }) }); }); } this.clearPolyline(); this.removeDragStartClasses(this.fromTaskClue); this.fromTaskClue = null; this.cancelScroll(); this.closeDragPopup(); } updatePolyline(start, end) { const points = `${start.left},${start.top} ${end.left},${end.top}`; this.renderer.setAttribute(this.polyline, 'points', points); } clearPolyline() { this.renderer.removeAttribute(this.polyline, 'points'); } assignDragStartClasses(dragClue) { if (!isPresent(dragClue)) { return; } this.renderer.addClass(this.container, USER_SELECT_NONE_CLASS); this.renderer.addClass(dragClue, DRAG_CLUE_HOVER_CLASS); const taskWrapper = getClosestTaskWrapper(dragClue, this.container); if (isPresent(taskWrapper)) { this.renderer.addClass(taskWrapper, TASK_WRAPPER_DRAG_CLASS); } } removeDragStartClasses(dragClue) { if (!isPresent(dragClue)) { return; } this.renderer.removeClass(this.container, USER_SELECT_NONE_CLASS); this.renderer.removeClass(dragClue, DRAG_CLUE_HOVER_CLASS); const taskWrapper = getClosestTaskWrapper(dragClue, this.container); if (isPresent(taskWrapper)) { this.renderer.removeClass(taskWrapper, TASK_WRAPPER_DRAG_CLASS); } } scrollPointIntoView({ left, top }) { this.timelineScrollService.requestScrollCancel(); this.timelineScrollService.requestHorizontalScroll(left); this.timelineScrollService.requestVerticalScroll(top); } cancelScroll() { this.timelineScrollService.requestScrollCancel(); } addScrollListener() { if (!isDocumentAvailable()) { return; } this.zone.runOutsideAngular(() => this.scrollListenerDisposer = this.renderer.listen(this.container, 'scroll', () => { // update the polyline only if we're currently dragging if (isPresent(this.fromTaskClue) && isPresent(this.currentPointerClientCoords)) { const { left, top } = this.currentPointerClientCoords; const pointerOffsetCoords = clientToOffsetCoords(left, top, this.container); this.updatePolyline(this.polylineStartCoords, pointerOffsetCoords); if (this.displayValidationTooltip) { this.updateDragPopup(pointerOffsetCoords); } } })); } removeScrollListener() { if (isPresent(this.scrollListenerDisposer)) { this.scrollListenerDisposer(); this.scrollListenerDisposer = null; } } openDragPopup() { if (isPresent(this.dragPopup)) { this.closeDragPopup(); } this.dragPopup = this.popupService.open({ animate: false, content: DragValidationTooltipComponent, appendTo: this.popupContainer, positionMode: 'absolute', popupClass: 'k-popup-transparent' }); } updateDragPopup(pointerOffsetPosition) { if (!isPresent(this.dragPopup)) { this.openDragPopup(); } const tooltip = this.dragPopup.content.instance; const { fromTaskName, toTaskName, isValid, showValidityStatus } = this.getTooltipContext(); if (tooltip.fromTaskName !== fromTaskName || tooltip.toTaskName !== toTaskName || tooltip.isValid !== isValid || tooltip.showValidityStatus !== showValidityStatus) { tooltip.fromTaskName = fromTaskName; tooltip.toTaskName = toTaskName; tooltip.isValid = isValid; tooltip.showValidityStatus = showValidityStatus; this.dragPopup.content.changeDetectorRef.detectChanges(); } this.dragPopup.popup.instance.offset = this.normalizePopupPosition(pointerOffsetPosition); this.dragPopup.popup.changeDetectorRef.detectChanges(); } closeDragPopup() { if (isPresent(this.dragPopup)) { this.dragPopup.close(); this.dragPopup = null; } } extractTaskName(target) { if (!isTaskWrapper(target, this.container)) { return null; } const taskIndex = getClosestTaskIndex(target, this.container); const task = this.gantt.renderedTreeListItems[taskIndex]; const taskName = this.mapper.extractFromTask(task, 'title'); return taskName; } getTooltipContext() { const fromTaskName = this.extractTaskName(this.fromTaskClue); const currentPointerTarget = elementFromPoint(this.currentPointerClientCoords.left, this.currentPointerClientCoords.top); const toTaskName = isTaskWrapper(currentPointerTarget, this.container) && !sameTaskClues(this.fromTaskClue, currentPointerTarget, this.container) ? this.extractTaskName(currentPointerTarget) : ''; const showValidityStatus = isDependencyDragClue(currentPointerTarget) && !sameTaskClues(this.fromTaskClue, currentPointerTarget, this.container); const { fromId, toId, type } = this.mapper.dependencyFields; return { fromTaskName, toTaskName, showValidityStatus, isValid: showValidityStatus && this.gantt.validateNewDependency({ [fromId]: this.mapper.extractFromTask(this.gantt.renderedTreeListItems[getClosestTaskIndex(this.fromTaskClue, this.container)], 'id'), [toId]: this.mapper.extractFromTask(this.gantt.renderedTreeListItems[getClosestTaskIndex(currentPointerTarget, this.container)], 'id'), [type]: getDependencyTypeFromTargetTasks(this.fromTaskClue, currentPointerTarget) }) }; } /** * Restricts the popup position to not go below the scroll height or width of the container. * Flips the position of the popup when there's not enough vertical space in the visible part of the container to render the popup. */ normalizePopupPosition(pointerOffsetPosition) { let top = pointerOffsetPosition.top + DEFAULT_POPUP_VERTICAL_MARGIN; const containerClientBottom = this.container.clientHeight + this.container.scrollTop; const popupHeight = this.dragPopup.popupElement.querySelector('.k-tooltip').clientHeight; const enoughSpaceToRender = top < containerClientBottom - popupHeight; // flip the popup above the pointer if there's not enough space in the bottom of the container if (!enoughSpaceToRender) { // margin * 2 to account for the already applied margin top -= popupHeight + (DEFAULT_POPUP_VERTICAL_MARGIN * 2); } // center the popup horizontally according to the pointer position const popupWidth = this.dragPopup.popupElement.querySelector('.k-tooltip').clientWidth; const left = pointerOffsetPosition.left - popupWidth / 2; // don't allow the popup to be cut out of the viewport const minLeftTop = 0; // restrict the popup from being positioned beyond or before the available scrollable space return { left: fitToRange(left, minLeftTop, this.container.scrollWidth - popupWidth), top: fitToRange(top, minLeftTop, this.container.scrollHeight - popupHeight) }; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DependencyDragCreateDirective, deps: [{ token: i1.GanttComponent }, { token: i0.NgZone }, { token: i0.Renderer2 }, { token: i2.MappingService }, { token: i3.PopupService }, { token: i4.TimelineScrollService }], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.14", type: DependencyDragCreateDirective, isStandalone: true, selector: "[kendoGanttDependencyDragCreate]", inputs: { displayValidationTooltip: "displayValidationTooltip" }, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DependencyDragCreateDirective, decorators: [{ type: Directive, args: [{ selector: '[kendoGanttDependencyDragCreate]', standalone: true }] }], ctorParameters: () => [{ type: i1.GanttComponent }, { type: i0.NgZone }, { type: i0.Renderer2 }, { type: i2.MappingService }, { type: i3.PopupService }, { type: i4.TimelineScrollService }], propDecorators: { displayValidationTooltip: [{ type: Input }] } });