@progress/kendo-angular-gantt
Version:
Kendo UI Angular Gantt
1,274 lines (1,261 loc) • 420 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 * as i0 from '@angular/core';
import { Injectable, Directive, Input, EventEmitter, inject, isDevMode, HostBinding, ViewChild, forwardRef, Component, Output, Optional, ViewContainerRef, ViewEncapsulation, QueryList, SkipSelf, Host, ContentChildren, ContentChild, HostListener, LOCALE_ID, Inject, NgModule } from '@angular/core';
import { ColumnBase, ColumnComponent, ColumnGroupComponent, SpanColumnComponent, TreeListSpacerComponent, DataBoundTreeComponent, ExpandableTreeComponent, TreeListComponent, CustomMessagesComponent as CustomMessagesComponent$2, FlatBindingDirective, HierarchyBindingDirective, ExpandableDirective, ColumnResizingService } from '@progress/kendo-angular-treelist';
import { cloneDate, addWeeks, firstDayInWeek, addDays, lastDayOfMonth, getDate, firstDayOfMonth, addMonths, lastMonthOfYear, MS_PER_HOUR, MS_PER_DAY, isEqual } from '@progress/kendo-date-math';
import { Subject, Subscription, fromEvent, forkJoin, EMPTY, isObservable, of } from 'rxjs';
import { validatePackage } from '@progress/kendo-licensing';
import { Keys, isDocumentAvailable, closestInScope, matchesClasses, isPresent as isPresent$1, EventsOutsideAngularDirective, DraggableDirective, PreventableEvent, anyChanged, closest, isFocusable, focusableSelector, isVisible, getLicenseMessage, shouldShowValidationUI, hasObservers, normalizeKeys, WatermarkOverlayComponent, ResizeBatchService } from '@progress/kendo-angular-common';
import { map, distinctUntilChanged, take, switchMap, expand, reduce, filter } from 'rxjs/operators';
import { getter, touchEnabled } from '@progress/kendo-common';
import * as i1 from '@progress/kendo-angular-intl';
import { DatePipe, NumberPipe } from '@progress/kendo-angular-intl';
import { orderBy } from '@progress/kendo-data-query';
import { xIcon, plusIcon, minusIcon, saveIcon, cancelOutlineIcon, trashIcon } from '@progress/kendo-svg-icons';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { IconWrapperComponent, IconsService } from '@progress/kendo-angular-icons';
import * as i6 from '@progress/kendo-angular-tooltip';
import { TooltipDirective, KENDO_TOOLTIP } from '@progress/kendo-angular-tooltip';
import * as i1$1 from '@progress/kendo-angular-l10n';
import { ComponentMessages, LocalizationService, L10N_PREFIX } from '@progress/kendo-angular-l10n';
import * as i4 from '@angular/forms';
import { FormArray, FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { GridComponent, SelectionDirective, ToolbarTemplateDirective as ToolbarTemplateDirective$1, ColumnComponent as ColumnComponent$1, CellTemplateDirective as CellTemplateDirective$1 } from '@progress/kendo-angular-grid';
import { ButtonComponent, ButtonGroupComponent, DropDownButtonComponent } from '@progress/kendo-angular-buttons';
import { DropDownListComponent } from '@progress/kendo-angular-dropdowns';
import { FormFieldComponent, TextBoxDirective, NumericTextBoxComponent } from '@progress/kendo-angular-inputs';
import { LabelComponent } from '@progress/kendo-angular-label';
import { DateTimePickerComponent, CalendarDOMService, CenturyViewService, DecadeViewService, MonthViewService, YearViewService, NavigationService as NavigationService$1, TimePickerDOMService, HoursService, MinutesService, SecondsService, MillisecondsService, DayPeriodService } from '@progress/kendo-angular-dateinputs';
import { DialogComponent, CustomMessagesComponent as CustomMessagesComponent$1, DialogActionsComponent, DialogContainerService, DialogService, WindowService, WindowContainerService } from '@progress/kendo-angular-dialog';
import { TabStripComponent, TabStripTabComponent, TabContentDirective, SplitterComponent, SplitterPaneComponent } from '@progress/kendo-angular-layout';
import * as i3 from '@progress/kendo-angular-popup';
import { PopupService } from '@progress/kendo-angular-popup';
import * as i3$1 from '@progress/kendo-angular-utils';
import { DragTargetContainerDirective } from '@progress/kendo-angular-utils';
/**
* @hidden
*/
const packageMetadata = {
name: '@progress/kendo-angular-gantt',
productName: 'Kendo UI for Angular',
productCode: 'KENDOUIANGULAR',
productCodes: ['KENDOUIANGULAR'],
publishDate: 1764751746,
version: '21.2.0',
licensingDocsUrl: 'https://www.telerik.com/kendo-angular-ui/my-license/?utm_medium=product&utm_source=kendoangular&utm_campaign=kendo-ui-angular-purchase-license-keys-warning'
};
/**
* @hidden
*/
const isArrowUpDownKey = (code) => [
Keys.ArrowUp,
Keys.ArrowDown
].some(arrowKeyCode => code === arrowKeyCode);
/**
* @hidden
*/
const isNavigationKey = (code) => [
Keys.ArrowUp,
Keys.ArrowDown,
Keys.Home,
Keys.End
].some(navigationKeyCode => code === navigationKeyCode);
/**
* @hidden
*/
const isExpandCollapseKey = (code, altKey) => {
return altKey && [
Keys.ArrowLeft,
Keys.ArrowRight
].some(navigationKeyCode => code === navigationKeyCode);
};
/**
* @hidden
*/
const isViewDigitKey = (code) => [
Keys.Digit1,
Keys.Numpad1,
Keys.Digit2,
Keys.Numpad2,
Keys.Digit3,
Keys.Numpad3,
Keys.Digit4,
Keys.Numpad4
].some(digitKeyCode => code === digitKeyCode);
/**
* @hidden
*
* Returns the corresponding view index for the pressed digit key (Digit 1 => 0, Digit 2 => 1, etc.).
*/
const getIndexFromViewDigitKeyCode = (code) => {
switch (code) {
case Keys.Numpad1:
case Keys.Digit1: return 0;
case Keys.Numpad2:
case Keys.Digit2: return 1;
case Keys.Numpad3:
case Keys.Digit3: return 2;
case Keys.Numpad4:
case Keys.Digit4: return 3;
default: return null;
}
};
/**
* @hidden
*/
class ScrollSyncService {
ngZone;
changes = new Subject();
elements = [];
subscriptions = new Subscription();
syncingTimeline;
syncingTreeList;
constructor(ngZone) {
this.ngZone = ngZone;
this.subscriptions.add(this.changes.subscribe(args => {
this.scroll(args);
}));
}
registerElement(el, sourceType) {
this.elements.push({ element: el, sourceType });
if (sourceType === "timeline" || sourceType === "treelist") {
this.ngZone.runOutsideAngular(() => {
const obs = fromEvent(el, 'scroll').pipe(map(({ target: { scrollTop, scrollLeft } }) => ({
scrollTop,
scrollLeft,
sourceType
})));
const comparisonFn = sourceType === 'timeline' ?
(x, y) => (x.scrollTop === y.scrollTop) && (x.scrollLeft === y.scrollLeft) :
(x, y) => (x.scrollTop === y.scrollTop);
this.subscriptions.add(obs.pipe(distinctUntilChanged(comparisonFn))
.subscribe((event) => this.changes.next(event)));
});
}
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
this.elements = null;
}
syncScrollTop(sourceType, targetType) {
const source = this.elements.find(element => element.sourceType === sourceType);
const target = this.elements.find(element => element.sourceType === targetType);
// Need to wait for the splitter pane's content to be rendered
this.ngZone.onStable.pipe(take(1)).subscribe(() => target.element.scrollTop = source.element.scrollTop);
}
resetTimelineScrollLeft() {
const source = this.elements.find(element => element.sourceType === 'timeline');
source.element.scrollLeft = 0;
}
scroll({ scrollTop, scrollLeft, sourceType }) {
this.ngZone.runOutsideAngular(() => {
if (sourceType === 'timeline') {
const header = this.elements.find(element => element.sourceType === 'header').element;
header.scrollLeft = scrollLeft;
if (!this.syncingTimeline) {
this.syncingTreeList = true;
const treelist = this.elements.find(element => element.sourceType === 'treelist').element;
treelist.scrollTop = scrollTop;
}
this.syncingTimeline = false;
}
if (sourceType === 'treelist') {
if (!this.syncingTreeList) {
this.syncingTimeline = true;
const timeline = this.elements.find(element => element.sourceType === 'timeline').element;
timeline.scrollTop = scrollTop;
}
this.syncingTreeList = false;
}
});
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ScrollSyncService, deps: [{ token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ScrollSyncService });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ScrollSyncService, decorators: [{
type: Injectable
}], ctorParameters: () => [{ type: i0.NgZone }] });
/**
* @hidden
*/
const DEFAULT_DEPENDENCY_MODEL_FIELDS = Object.freeze({
toId: 'toId',
fromId: 'fromId',
id: 'id',
type: 'type'
});
/**
* @hidden
*/
const DEFAULT_TASK_MODEL_FIELDS = Object.freeze({
id: 'id',
start: 'start',
end: 'end',
title: 'title',
completionRatio: 'completionRatio',
children: 'children'
});
/**
* Represents the dependency type when you connect two tasks.
*
* The supported values are:
* * `FF`—from 'finish' to 'finish'
* * `FS`—from 'finish' to 'start'
* * `SS`—from 'start' to 'start'
* * `SF`—from 'start' to 'finish'
*/
var DependencyType;
(function (DependencyType) {
/**
* Specifies that task B can't finish before task A finishes.
*/
DependencyType[DependencyType["FF"] = 0] = "FF";
/**
* Specifies that task B can't start before task A finishes.
*/
DependencyType[DependencyType["FS"] = 1] = "FS";
/**
* Specifies that task A can't finish before task B starts.
*/
DependencyType[DependencyType["SF"] = 2] = "SF";
/**
* Specifies that task B can't start before task A starts.
*/
DependencyType[DependencyType["SS"] = 3] = "SS";
})(DependencyType || (DependencyType = {}));
/**
* @hidden
*/
const isWorkDay = (date, start, end) => {
return date.getDay() >= start && date.getDay() <= end;
};
/**
* @hidden
*/
const isWorkHour = (date, start, end) => {
return date.getHours() >= start && date.getHours() <= end;
};
/**
* @hidden
*/
const isPresent = (item) => item !== null && item !== undefined;
/**
* @hidden
*
* Normalized the data to an array in case a falsy value is passed
* or a TreeListDataResult object (applicable for the data-binding directives).
*/
const normalizeGanttData = (data) => {
if (!isPresent(data)) {
return [];
}
else if (Array.isArray(data.data)) {
return data.data;
}
else {
return data;
}
};
/**
* @hidden
*/
const isArray = (value) => Array.isArray(value);
/**
* @hidden
*
* Returns a new date with the specified hours, minutes, seconds and millliseconds set.
* Only the hours are required, the rest of the params are set to `0` by default.
*/
const setTime$1 = (date, hours, minutes = 0, seconds = 0, milliseconds = 0) => {
if (!isPresent(date)) {
return null;
}
const result = cloneDate(date);
result.setHours(hours);
result.setMinutes(minutes);
result.setSeconds(seconds);
result.setMilliseconds(milliseconds);
return result;
};
/**
* @hidden
*
* Returns the last day of a week.
* @param standingPoint - Any day of the target week.
* @param firstWeekDay - The week's starting day (e.g. Monday, Tuesday, etc.)
*/
const lastDayOfWeek = (standingPoint, firstWeekDay) => {
const followingWeek = addWeeks(standingPoint, 1);
const firstDayOfFollowingWeek = firstDayInWeek(followingWeek, firstWeekDay);
const lastDayOfTargetWeek = addDays(firstDayOfFollowingWeek, -1);
return lastDayOfTargetWeek;
};
/**
* @hidden
*
* Returns the total number of days in a month
*/
const getTotalDaysInMonth = (date) => {
return lastDayOfMonth(date).getDate();
};
/**
* @hidden
*
* Returns the total number of months between two dates
* by excluding the months of the dates themselves.
*/
const getTotalMonthsInBetween = (start, end) => {
const diff = end.getMonth() - start.getMonth() + (12 * (end.getFullYear() - start.getFullYear()));
return diff <= 1 ? 0 : diff - 1;
};
/**
* Persists the initially resolved scrollbar width value.
*/
let SCROLLBAR_WIDTH;
/**
* @hidden
*
* Gets the default scrollbar width accoring to the current environment.
*/
const scrollbarWidth = () => {
if (!isDocumentAvailable()) {
return;
}
// calculate scrollbar width only once, then return the cached value
if (isNaN(SCROLLBAR_WIDTH)) {
const div = document.createElement('div');
div.style.cssText = 'overflow: scroll; overflow-x: hidden; zoom: 1; clear: both; display: block;';
div.innerHTML = ' ';
document.body.appendChild(div);
SCROLLBAR_WIDTH = div.offsetWidth - div.scrollWidth;
document.body.removeChild(div);
}
return SCROLLBAR_WIDTH;
};
/**
* @hidden
*/
const isColumnGroup = (column) => column.isColumnGroup;
/**
* @hidden
*/
const isNumber = (contender) => typeof contender === 'number' && !isNaN(contender);
/**
* @hidden
*/
const isString = (contender) => typeof contender === 'string';
/**
* @hidden
*
* Gets the closest timeline task wrapper element from an event target.
* Restricts the search up to the provided parent element from the second param.
*/
const getClosestTaskWrapper = (element, parentScope) => {
return closestInScope(element, matchesClasses('k-task-wrap'), parentScope);
};
/**
* @hidden
*
* Checks whether the queried item or its parent items has a `k-task-wrap` selector.
* Restricts the search up to the provided parent element from the second param.
*/
const isTaskWrapper = (contender, parentScope) => {
const taskWrapper = closestInScope(contender, matchesClasses('k-task-wrap'), parentScope);
return isPresent(taskWrapper);
};
/**
* @hidden
*
* Gets the closest timeline task element from an event target.
* Restricts the search up to the provided parent element from the second param.
*/
const getClosestTask = (element, parentScope) => {
return closestInScope(element, matchesClasses('k-task'), parentScope);
};
/**
* @hidden
*
* Gets the closest timeline task element index from an event target.
* Uses the `data-task-index` attribute assigned to each task.
* Restricts the search up to the provided parent element from the second param.
*/
const getClosestTaskIndex = (element, parentScope) => {
const task = closestInScope(element, matchesClasses('k-task-wrap'), parentScope);
if (!isPresent(task)) {
return null;
}
return Number(task.getAttribute('data-task-index'));
};
/**
* @hidden
*
* Checks whether the queried item or its parent items has a `k-task` selector.
* Restricts the search up to the provided parent element from the second param.
*/
const isTask = (contender, parentScope) => {
const task = closestInScope(contender, matchesClasses('k-task'), parentScope);
return isPresent(task);
};
/**
* @hidden
*
* Checks whether the queried item or its parent items has a `k-toolbar` selector.
* Restricts the search up to the provided parent element from the second param.
*/
const isToolbar = (contender, parentScope) => {
const toolbar = closestInScope(contender, matchesClasses('k-gantt-toolbar'), parentScope);
return isPresent(toolbar);
};
/**
* @hidden
*
* Checks whether the queried item or its parent items has a `k-task-actions` selector - used for the clear button.
* Restricts the search up to the provided parent element from the second param.
*/
const isClearButton = (contender, parentScope) => {
const clearButtonContainer = closestInScope(contender, matchesClasses('k-task-actions'), parentScope);
return isPresent(clearButtonContainer);
};
/**
* @hidden
*
* Checks whether the queried item has a `k-task-dot` selector - used for the dependency drag clues.
*/
const isDependencyDragClue = (element) => {
if (!isPresent(element)) {
return false;
}
return element.classList.contains('k-task-dot');
};
/**
* @hidden
*
* Checks whether the queried item has a `k-task-dot` & `k-task-start` selector - used for the dependency drag start clues.
*/
const isDependencyDragStartClue = (element) => {
if (!isPresent(element)) {
return false;
}
return element.classList.contains('k-task-dot') && element.classList.contains('k-task-start');
};
/**
* @hidden
*
* Gets the `DependencyType` for an attempted dependency create from the provided two elements.
* The two linked drag clue HTML elements are used to extract this data (via their CSS classes).
*/
const getDependencyTypeFromTargetTasks = (fromTaskClue, toTaskClue) => {
if (!isDependencyDragClue(fromTaskClue) || !isDependencyDragClue(toTaskClue)) {
return null;
}
const fromTaskType = isDependencyDragStartClue(fromTaskClue) ? 'S' : 'F';
const toTaskType = isDependencyDragStartClue(toTaskClue) ? 'S' : 'F';
const dependencyTypeName = `${fromTaskType}${toTaskType}`;
switch (dependencyTypeName) {
case 'FF': return DependencyType.FF;
case 'FS': return DependencyType.FS;
case 'SF': return DependencyType.SF;
case 'SS': return DependencyType.SS;
default: return null;
}
};
/**
* @hidden
*
* Checks whether the two provided drag clues belong to the same task element.
*/
const sameTaskClues = (fromTaskClue, toTaskClue, parentScope) => {
if (!isPresent(fromTaskClue) || !isPresent(toTaskClue)) {
return false;
}
const fromTaskWrapper = getClosestTaskWrapper(fromTaskClue, parentScope);
const toTaskWrapper = getClosestTaskWrapper(toTaskClue, parentScope);
return fromTaskWrapper === toTaskWrapper;
};
/**
* @hidden
*
* Fits a contender number between a min and max range.
* If the contender is below the min value, the min value is returned.
* If the contender is above the max value, the max value is returned.
*/
const fitToRange = (contender, min, max) => {
if (!isPresent(contender) || contender < min) {
return min;
}
else if (contender > max) {
return max;
}
else {
return contender;
}
};
/**
* @hidden
*
* Checks whether either of the two provided tasks is a parent of the other.
*/
const areParentChild = (taskA, taskB) => {
let parentChildRelationship = false;
let taskAParent = taskA;
while (isPresent(taskAParent) && isPresent(taskAParent.data)) {
if (taskAParent.data === taskB.data) {
parentChildRelationship = true;
break;
}
taskAParent = taskAParent.parent;
}
let taskBParent = taskB;
while (!parentChildRelationship && isPresent(taskBParent) && isPresent(taskBParent.data)) {
if (taskBParent.data === taskA.data) {
parentChildRelationship = true;
break;
}
taskBParent = taskBParent.parent;
}
return parentChildRelationship;
};
/**
* @hidden
*
* Extracts an element from the provided client coords.
* Using the `event.target` is not reliable under mobile devices with the current implementation of the draggable, so use this instead.
*/
const elementFromPoint = (clientX, clientY) => {
if (!isDocumentAvailable()) {
return null;
}
return document.elementFromPoint(clientX, clientY);
};
/**
* @hidden
*/
class MappingService {
/**
* Gets or sets the model fields for the task data items.
* Uses the default values for fields which are not specified.
*/
set taskFields(fields) {
this._taskFields = { ...DEFAULT_TASK_MODEL_FIELDS, ...fields };
}
get taskFields() {
return this._taskFields;
}
/**
* Gets or sets the model fields for the depenency data items.
* Uses the default values for fields which are not specified.
*/
set dependencyFields(fields) {
this._dependencyFields = { ...DEFAULT_DEPENDENCY_MODEL_FIELDS, ...fields };
}
get dependencyFields() {
return this._dependencyFields;
}
_taskFields = { ...DEFAULT_TASK_MODEL_FIELDS };
_dependencyFields = { ...DEFAULT_DEPENDENCY_MODEL_FIELDS };
/**
* Retrieves the value for the specified task field.
* Supports nested fields as well (e.g. 'manager.id').
*/
extractFromTask(dataItem, field) {
if (!isPresent(this.taskFields)) {
return null;
}
return getter(this.taskFields[field])(dataItem);
}
/**
* Retrieves the value for the specified dependency field.
* Supports nested fields as well (e.g. 'manager.id').
*/
extractFromDependency(dataItem, field) {
if (!isPresent(this.dependencyFields)) {
return null;
}
return getter(this.dependencyFields[field])(dataItem);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MappingService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MappingService });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MappingService, decorators: [{
type: Injectable
}] });
/**
* @hidden
*/
class DependencyDomService {
mapper;
/**
* Emits each time some of the tasks or the view have changed.
* Fires also on the first change of the table rows and the parent container.
*/
get taskChanges() {
return this.notifier.asObservable();
}
get dependencyDomArgs() {
return {
tasks: this.tasks,
contentContainer: this.contentContainer,
timelineRow: this.timelineRow
};
}
notifier = new Subject();
/**
* The row element in the Timeline part of the Gantt.
*/
timelineRow;
/**
* Used to get retrieve the offset of the task elements relative to the parent container.
*/
contentContainer;
/**
* Maps each rendered task to its HTML element.
* Uses the task ID field value as key.
*/
tasks = new Map();
constructor(mapper) {
this.mapper = mapper;
}
ngOnDestroy() {
this.tasks.clear();
this.tasks = null;
this.contentContainer = null;
}
registerTimelineRow(timelineRow) {
this.timelineRow = timelineRow;
this.notifyChanges();
}
registerContentContainer(contentContainer) {
this.contentContainer = contentContainer;
this.notifyChanges();
}
registerTask(task, element) {
const id = this.mapper.extractFromTask(task, 'id');
this.tasks.set(id, element);
this.notifyChanges();
}
unregisterTask(task) {
const id = this.mapper.extractFromTask(task, 'id');
this.tasks.delete(id);
this.notifyChanges();
}
/**
* Notifies all dependency directives that a change in one of the elements has occured.
*/
notifyChanges() {
this.notifier.next(this.dependencyDomArgs);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DependencyDomService, deps: [{ token: MappingService }], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DependencyDomService });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DependencyDomService, decorators: [{
type: Injectable
}], ctorParameters: () => [{ type: MappingService }] });
const MS_PER_SECOND = 1000;
const MS_PER_MINUTE = 60 * MS_PER_SECOND;
/**
* @hidden
*/
class CurrentTimeMarkerService {
renderer;
cdr;
container;
slots = [];
rows = [];
currentTimeMarker;
rowHeight;
rtl;
activeView;
get deltaOffset() {
if (this.slotIndex >= 0) {
const total = this.slots[this.slotIndex].end.getTime() - this.slots[this.slotIndex].start.getTime();
if (total > 0) {
const currentTimeValue = this.now.getTime() - this.slots[this.slotIndex].start.getTime();
const fractionInsideCell = currentTimeValue / total;
const deltaOffsetToSlot = this.slotIndex * this.slotWidth;
const deltaOffsetInsideSlot = fractionInsideCell * this.slotWidth;
return deltaOffsetToSlot + deltaOffsetInsideSlot;
}
return 0;
}
}
get slotWidth() {
return this.slots[0]?.slotWidth;
}
get slotIndex() {
return this.slots.indexOf(this.slots.find((slot) => slot.start <= this.now && slot.end > this.now));
}
get height() {
return this.rows.length * this.rowHeight;
}
get interval() {
if (typeof (this.currentTimeMarker) === 'boolean') {
return MS_PER_MINUTE;
}
return this.currentTimeMarker?.updateInterval || MS_PER_MINUTE;
}
now = new Date(Date.now());
currentTimeTimeout;
timeMarkerDiv;
constructor(renderer, cdr) {
this.renderer = renderer;
this.cdr = cdr;
}
ngOnDestroy() {
clearTimeout(this.currentTimeTimeout);
}
removeTimeMarker() {
if (this.timeMarkerDiv) {
this.renderer.removeChild(this.container.nativeElement, this.timeMarkerDiv);
clearTimeout(this.currentTimeTimeout);
this.cdr.detectChanges();
}
}
createTimeMarker = () => {
if (!isDocumentAvailable()) {
return;
}
this.removeTimeMarker();
if (this.slotIndex >= 0) {
this.now = new Date(Date.now());
this.timeMarkerDiv = this.renderer.createElement('div');
this.renderer.addClass(this.timeMarkerDiv, 'k-current-time');
this.renderer.setStyle(this.timeMarkerDiv, 'width', '1px');
this.renderer.setStyle(this.timeMarkerDiv, 'top', '0px');
this.renderer.setStyle(this.timeMarkerDiv, `${this.rtl ? 'right' : 'left'}`, this.deltaOffset + 'px');
this.renderer.appendChild(this.container.nativeElement, this.timeMarkerDiv);
this.renderer.setStyle(this.timeMarkerDiv, 'height', this.height + 'px');
this.currentTimeTimeout = setTimeout(this.createTimeMarker, this.interval || MS_PER_MINUTE);
}
};
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: CurrentTimeMarkerService, deps: [{ token: i0.Renderer2 }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: CurrentTimeMarkerService });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: CurrentTimeMarkerService, decorators: [{
type: Injectable
}], ctorParameters: () => [{ type: i0.Renderer2 }, { type: i0.ChangeDetectorRef }] });
/**
* @hidden
*
* Gets the offset (top and left values) relative to a target element.
*/
const getOffsetRelativeToParent = (element, targetParent) => {
const offset = {
top: 0,
left: 0
};
if (!targetParent.contains(element)) {
return offset;
}
let offsetParent = element;
while (offsetParent && offsetParent !== targetParent) {
offset.top += offsetParent.offsetTop;
offset.left += offsetParent.offsetLeft;
offsetParent = offsetParent.offsetParent;
}
return offset;
};
/**
* @hidden
*/
const getElementRect = (element, relativeContainer) => {
const { top, left } = getOffsetRelativeToParent(element, relativeContainer);
return {
top: top + element.offsetHeight / 2,
left: left,
right: left + element.offsetWidth
};
};
/**
* @hidden
*/
const dependencyCoordinates = (from, to, rowHeight, type, minDistanceBeforeTurn, arrowSize) => {
const points = [];
const minTurnHeight = Math.floor(rowHeight / 2);
const drawingDown = from.top < to.top;
let top, left;
// FF and SS are composed of 4 connected polyline points (not counting the arrow)
/*
[[[]]]- -[[[]]]
| |
[[[]]]- -[[[]]]
*/
if (type === DependencyType.FF || type === DependencyType.SS) {
// polyline start from first task
const dir = type === DependencyType.SS ? 'left' : 'right';
top = from.top;
left = from[dir];
points.push({ top, left });
// first turn point
left = Math[dir === 'left' ? 'min' : 'max'](from[dir], to[dir]);
left = dir === 'left' ? left - minDistanceBeforeTurn : left + minDistanceBeforeTurn;
points.push({ top, left });
// second turn point
top = to.top;
points.push({ top, left });
// second task reached
left = dir === 'left' ? to[dir] - arrowSize : to[dir] + arrowSize;
points.push({ top, left });
// arrow pointing to the second task
points.push(...getArrow(top, left, dir !== 'left', arrowSize));
}
else {
// FS and SF are composed of 4 or 6 connected polyline points (not counting the arrow), depending on the position of the tasks
/*
[[[]]]- [[[]]]-
| |
-[[[]]] -----
|
-[[[]]]
*/
const startDir = type === DependencyType.SF ? 'left' : 'right';
const endDir = type === DependencyType.SF ? 'right' : 'left';
const additionalTurn = type === DependencyType.SF
? from[startDir] - minDistanceBeforeTurn * 2 < to[endDir]
: from[startDir] + minDistanceBeforeTurn * 2 > to[endDir];
// polyline start from first task
top = from.top;
left = from[startDir];
points.push({ top, left });
// first turn point
left = startDir === 'left'
? left - minDistanceBeforeTurn
: left + minDistanceBeforeTurn;
points.push({ top, left });
// if second task start is before the first task end in FS
// if second task end is after the first task start in SF
if (additionalTurn) {
// additional turn start
top = drawingDown
? top + minTurnHeight
: top - minTurnHeight;
points.push({ top, left });
// additional turn end
left = startDir === 'left'
? to[endDir] + minDistanceBeforeTurn
: to[endDir] - minDistanceBeforeTurn;
points.push({ top, left });
}
// second task level reached
top = to.top;
points.push({ top, left });
// second task element reached
left = endDir === 'left' ? to[endDir] - arrowSize : to[endDir] + arrowSize;
points.push({ top, left });
// arrow pointing to the second task
points.push(...getArrow(top, left, endDir !== 'left', arrowSize));
}
return points;
};
const getArrow = (top, left, isArrowWest, arrowSize) => {
const points = isArrowWest
? getArrowWest(top, left, arrowSize)
: getArrowEast(top, left, arrowSize);
return points;
};
const getArrowWest = (top, left, arrowSize) => {
const points = [];
points.push({
top: top - arrowSize / 2,
left
});
points.push({
top,
left: left - arrowSize + 1
});
points.push({
top: top + arrowSize / 2,
left
});
points.push({
top,
left
});
return points;
};
const getArrowEast = (top, left, arrowSize) => {
const points = [];
points.push({
top: top + arrowSize / 2,
left
});
points.push({
top,
left: left + arrowSize - 1
});
points.push({
top: top - arrowSize / 2,
left
});
points.push({
top,
left
});
return points;
};
/**
* @hidden
*
* Translates the provided client `left` and `top` coords to coords relative to the provided container.
* https://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_View/Coordinate_systems#standard_cssom_coordinate_systems
*/
const clientToOffsetCoords = (clientLeft, clientTop, offsetContainer) => {
// client (viewport) coordinates of the target container
// https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect#value
const offsetContainerClientRect = offsetContainer.getBoundingClientRect();
return {
left: clientLeft - offsetContainerClientRect.left + offsetContainer.scrollLeft,
top: clientTop - offsetContainerClientRect.top + offsetContainer.scrollTop
};
};
/**
* @hidden
*
* Retrieves the `left` and `top` values of the center of the provided element.
* The retrieved values are relative to the current viewport (client values).
* https://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_View/Coordinate_systems#standard_cssom_coordinate_systems
*/
const getElementClientCenterCoords = (element) => {
// client (viewport) coordinates of the targeted element
// https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect#value
const { left, top, width, height } = element.getBoundingClientRect();
return {
left: left + (width / 2),
top: top + (height / 2)
};
};
/**
* Defines the size of the arrow that will be drawn at the end of each Gantt dependency.
*/
const ARROW_SIZE = 4;
/**
* Defines the distance the polyline will cover from the task element before making a turn.
*/
const MIN_DISTANCE_BEFORE_TURN = 10;
/**
* @hidden
*/
class GanttDependencyDirective {
polyline;
zone;
renderer;
mapper;
dependencyDomService;
dependency;
subscriptions = new Subscription();
constructor(polyline, zone, renderer, mapper, dependencyDomService) {
this.polyline = polyline;
this.zone = zone;
this.renderer = renderer;
this.mapper = mapper;
this.dependencyDomService = dependencyDomService;
this.subscriptions.add(dependencyDomService.taskChanges
.pipe(switchMap(changes =>
// reacts only on the very last event emission,
// ensures that the tasks are drawn in the DOM
this.zone.onStable.pipe(take(1), map(() => changes))))
.subscribe(changes => this.updatePoints(changes)));
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
}
ngOnChanges(changes) {
if (isPresent(changes['dependency'])) {
this.updatePoints(this.dependencyDomService.dependencyDomArgs);
}
}
updatePoints({ timelineRow, contentContainer, tasks }) {
if (!isPresent(timelineRow) || !isPresent(contentContainer) ||
!isPresent(tasks) || tasks.size === 0 ||
!tasks.has(this.mapper.extractFromDependency(this.dependency, 'fromId')) || !tasks.has(this.mapper.extractFromDependency(this.dependency, 'toId'))) {
this.clearPoints();
return;
}
const fromCoordinates = getElementRect(tasks.get(this.mapper.extractFromDependency(this.dependency, 'fromId')), contentContainer);
const toCoordinates = getElementRect(tasks.get(this.mapper.extractFromDependency(this.dependency, 'toId')), contentContainer);
const timelineRowHeight = isDocumentAvailable() ? timelineRow.getBoundingClientRect().height : 0;
const points = dependencyCoordinates(fromCoordinates, toCoordinates, timelineRowHeight, this.dependency.type, MIN_DISTANCE_BEFORE_TURN, ARROW_SIZE);
this.drawPoints(points);
}
clearPoints() {
this.renderer.setAttribute(this.polyline.nativeElement, 'points', '');
}
drawPoints(points) {
if (!isPresent(points) || points.length === 0) {
this.clearPoints();
return;
}
const parsedCoords = points.map(({ left, top }) => `${left},${top}`).join(' ');
this.renderer.setAttribute(this.polyline.nativeElement, 'points', parsedCoords);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: GanttDependencyDirective, deps: [{ token: i0.ElementRef }, { token: i0.NgZone }, { token: i0.Renderer2 }, { token: MappingService }, { token: DependencyDomService }], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.14", type: GanttDependencyDirective, isStandalone: true, selector: "[kendoGanttDependency]", inputs: { dependency: "dependency" }, usesOnChanges: true, ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: GanttDependencyDirective, decorators: [{
type: Directive,
args: [{
selector: '[kendoGanttDependency]',
standalone: true
}]
}], ctorParameters: () => [{ type: i0.ElementRef }, { type: i0.NgZone }, { type: i0.Renderer2 }, { type: MappingService }, { type: DependencyDomService }], propDecorators: { dependency: [{
type: Input
}] } });
/**
* @hidden
*/
class NavigationService {
zone;
renderer;
scrollSyncService;
/**
* Notifies when the tasks' focused and interactive (tabindex) state has changed.
*
* All tasks are rendered with tabindex="-1".
* When one is clicked, or when some navigation key keyboard key is pressed, it should be focused, assigned the focus class, and its tabindex updated to 0.
* All other tasks should get -1 tabindex and have the focus class removed from them.
*/
taskStatusChanges = new Subject();
/**
* Specifies whether navigation is enabled.
*/
get enabled() {
return this._enabled;
}
/**
* Used to retrieve read-only data about the currently active task.
*/
get activeTask() {
return {
activeIndex: this.activeTimelineIndex,
isFocused: this.isTimelineFocused
};
}
/**
* Persists the expected Timeline focused task index.
* When the cells in the TreeList are navigated through, the expected Timeline focus target should also change,
* in order to allow tabbing from the TreeList to the same row in the Timeline.
*/
set activeTimelineIndex(index) {
this._activeTimelineIndex = index;
}
get activeTimelineIndex() {
const firstAvailableIndex = 0;
const lastAvailableIndex = this.metadata.treeList.view.data.length - 1;
return fitToRange(this._activeTimelineIndex, firstAvailableIndex, lastAvailableIndex);
}
/**
* Persists the expected TreeList focused cell coords.
* When the tasks in the Timeline are navigated through, the expected TreeList focus target should also change,
* in order to allow back-tabbing from the Timeline to the same row in the TreeList.
*/
set activeTreeListCell(cell) {
this._activeTreeListCell = cell;
}
get activeTreeListCell() {
const firstAvailableIndex = 0;
const lastAvailableRowIndex = this.treeListHeaderRowsCount + this.metadata.treeList.view.data.length - 1;
const rowIndex = fitToRange(this._activeTreeListCell.rowIndex, firstAvailableIndex, lastAvailableRowIndex);
const lastAvailableColIndex = this.metadata.columns.length;
const colIndex = fitToRange(this._activeTreeListCell.colIndex, firstAvailableIndex, lastAvailableColIndex);
return { rowIndex, colIndex };
}
/**
* Keeps track of whether the Timeline part is focused.
* Used when the index of the task elements change (tasks are changed, pushed to, spliced from, etc.)
* and their status should be updated accordingly.
*/
isTimelineFocused = false;
/**
* The TreeList row index takes into account the header and filter rows.
* Used when translating Timeline task indices to TreeList row indices.
*/
get treeListHeaderRowsCount() {
// captures nested group header rows + filter row if we start supporting it at some point
return this.metadata.treeListElement.querySelectorAll('.k-grid-header tr').length;
}
/**
* Keeps track of which part has last been focused.
* Used when calling `gantt.focus()` to determine which part of the component should receive focus.
*/
treeListLastActive = false;
/**
* Keeps track of which part has last been focused.
* Used when calling `gantt.focus()` to determine which part of the component should receive focus.
*/
timelineLastActive = false;
metadata;
_enabled = false;
_activeTimelineIndex = 0;
_activeTreeListCell = { rowIndex: 0, colIndex: 0 };
eventListenerDisposers;
constructor(zone, renderer, scrollSyncService) {
this.zone = zone;
this.renderer = renderer;
this.scrollSyncService = scrollSyncService;
}
initialize(metadata) {
// no private property setters in TypeScript, so use a getter and a poorly named private prop for this value
this._enabled = true;
this.metadata = metadata;
// TODO: fix in the splitter package and remove
// move the splitbar HTML element between the two panes to keep the visial tabbing order in tact
const splitbar = this.metadata.host.querySelector('.k-splitbar');
if (isPresent(splitbar) && isPresent(splitbar.previousElementSibling) && isPresent(splitbar.after)) {
splitbar.after(splitbar.previousElementSibling);
}
this.zone.runOutsideAngular(() => {
this.eventListenerDisposers = [
this.renderer.listen(this.metadata.treeListElement, 'mousedown', this.focusTreeList.bind(this)),
this.renderer.listen(this.metadata.treeListElement, 'focusin', this.handleTreeListFocusIn.bind(this)),
this.renderer.listen(this.metadata.timelineElement, 'mousedown', this.handleTimelineMousedown.bind(this)),
this.renderer.listen(this.metadata.timelineElement, 'focusin', this.handleTimelineFocusIn.bind(this)),
this.renderer.listen(this.metadata.timelineElement, 'focusout', this.handleTimelineFocusOut.bind(this))
];
});
}
ngOnDestroy() {
if (isPresent(this.eventListenerDisposers)) {
this.eventListenerDisposers.forEach(removeListener => removeListener());
this.eventListenerDisposers = null;
}
this.metadata = null;
}
/**
* Focuses either the last active TreeList cell, or the last active Timeline task,
* dependening on which of the two last held focus.
*
* Focuses the first TreeList cell by default.
*/
focusLastActiveItem() {
if (this.metadata.data.length === 0 || (!this.treeListLastActive && !this.timelineLastActive)) {
this.focusCell(0, 0);
}
else if (this.treeListLastActive) {
const { rowIndex, colIndex } = this.activeTreeListCell;
this.metadata.treeList.focusCell(rowIndex, colIndex);
}
else if (this.timelineLastActive) {
this.focusTask(this.activeTimelineIndex);
}
}
/**
* Focuses the targeted TreeList cell regardless of the last peresisted target.
*/
focusCell(rowIndex, colIndex) {
this.activeTreeListCell = { rowIndex, colIndex };
this.activeTimelineIndex = rowIndex - this.treeListHeaderRowsCount;
this.metadata.treeList.focusCell(this.activeTreeListCell.rowIndex, this.activeTreeListCell.colIndex);
}
/**
* Focuses the targeted Timeline task regardless of the last peresisted target.
*/
focusTask(index) {
this.activeTimelineIndex = index;
this.isTimelineFocused = true;
this.activeTreeListCell = {
rowIndex: index + this.treeListHeaderRowsCount,
colIndex: this.activeTreeListCell.colIndex
};
this.notifyTaskStatusChange();
}
/**
* Updates the focus target flags and notifies the active task to update its focused state.
*/
handleTimelineFocusIn({ target }) {
this.treeListLastActive = false;
this.timelineLastActive = true;
this.isTimelineFocused = true;
if (isTask(target, this.metadata.timelineElement)) {
this.notifyTaskStatusChange();
}
}
/**
* Updates the timeline focus state flag and notifies the active task to update its focused state.
*/
handleTimelineFocusOut({ relatedTarget }) {
this.isTimelineFocused = this.metadata.timelineElement.contains(relatedTarget);
// update the task element only if the new focus target is not in the Timeline - focus change between tasks is handled in the focusin handler
if (!isTask(relatedTarget, this.metadata.timelineElement)) {
this.notifyTaskStatusChange();
}
}
/**
* Updates the focus target flags and corrects the TreeList focus target if needed.
* As the TreeList will keep its last focused cell with tabindex="0",
* this methods forcefully focuses the correct cell,
* when navigating in the Timeline has updated the expected TreeList focus target.
*/
handleTreeListFocusIn(event) {
this.treeListLastActive = true;
this.timelineLastActive = false;
// if the previous focus target was in the TreeList, rely on its component navigation and just record the focused item index
if (this.metadata.treeListElement.contains(event.relatedTarget)) {
const { colIndex, rowIndex } = this.metadata.treeList.activeCell;
this.activeTreeListCell = { colIndex, rowIndex };
}
else {
// if the previous focus target was outside the TreeList, ensure the expected focus coords are used
const { rowIndex, colIndex } = this.activeTreeListCell;
this.metadata.treeList.focusCell(rowIndex, colIndex); // activates the target cell even if it has tabindex="-1"
}
this.activeTimelineIndex = this.metadata.treeList.activeCell.dataRowIndex;
this.notifyTaskStatusChange();
if (this.metadata.treeList.activeCell.dataRowIndex >= 0) {
this.scrollHorizontallyToTask();
this.scrollSyncService.syncScrollTop('treelist', 'timeline');
}
}
updateActiveTimeLineIndex(index) {
this.activeTimelineIndex = index;
}
updateActiveTreeListCell() {
this.activeTreeListCell = {
rowIndex: this.activeTimelineIndex + this.treeListHeaderRowsCount,
colIndex: this.activeTreeListCell.colIndex
};
}
/**
* Fires the `taskStatusChanges` event with active and focused status retrieved from
* `this.activeTimelineIndex` and `this.isTimelineFocused`.
*/
notifyTaskStatusChange() {
this.taskStatusChanges.next(this.activeTask);
}
/**
* Scrolls horizontally to the beginning of the target task if the beginning of its content is not in the viewport.
*/
scrollHorizontallyToTask() {
const index = this.activeTimelineIndex;
const task = this.metadata.timelineElement.querySelectorAll('.k-task-wrap').item(index);
if (!isPresent(task)) {
return;
}
// scroll horizontally to the item if less than 200px from the beginning of its content are visible
const targetVisibleWidth = 200;
const isScrollBeforeTask = (this.metadata.timelineElement.clientWidth + this.metadata.timelineElement.scrollLeft) < (task.offsetLeft + targetVisibleWidth);
const isScrollAfterTask = this.metadata.timelineElement.scrollLeft > task.offsetLeft;
if (isScrollBeforeTask || isScrollAfterTask) {
this.metadata.timelineElement.scrollLeft = task.offsetLeft;
}
}
/**
* Filters for task mousedown in the Timeline.
*/
handleTimelineMousedown({ target }) {
if (isTask(target, this.metadata.host) && !isClearButton(target, this.metadata.host)) {
const taskIndex = getClosestTaskIndex(target, this.metadata.host);
this.focusTask(taskIndex);
}
}
/**
* Focus the TreeList on TreeList mousedown.
* A nasty hack to trick `handleTreeListFocusIn` into regarding the previous focus target as again the TreeList.
* Otherwise cell clicks are wrongly overwritten in `handleTreeListFocusIn` and the click focus target is not respected.
*/
focusTreeList() {
this.metadata.treeList.focus();
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NavigationService, deps: [{ token: i0.NgZone }, { token: i0.Renderer2 }, { token: ScrollSyncService }], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersio