@progress/kendo-angular-gantt
Version:
Kendo UI Angular Gantt
338 lines (337 loc) • 16.7 kB
JavaScript
/**-----------------------------------------------------------------------------------------
* 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
}] } });