UNPKG

@progress/kendo-angular-gantt

Version:
1,274 lines (1,261 loc) 420 kB
/**----------------------------------------------------------------------------------------- * 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`&mdash;from 'finish' to 'finish' * * `FS`&mdash;from 'finish' to 'start' * * `SS`&mdash;from 'start' to 'start' * * `SF`&mdash;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 = '&nbsp;'; 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