super-productivity/src/app/features/tasks/task/task.component.ts

670 lines
20 KiB
TypeScript

import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
HostBinding,
HostListener,
Input,
OnDestroy,
OnInit,
Renderer2,
ViewChild
} from '@angular/core';
import {TaskService} from '../task.service';
import {Observable, of, ReplaySubject, Subject} from 'rxjs';
import {ShowSubTasksMode, TaskAdditionalInfoTargetPanel, TaskWithSubTasks} from '../task.model';
import {MatDialog} from '@angular/material/dialog';
import {DialogTimeEstimateComponent} from '../dialog-time-estimate/dialog-time-estimate.component';
import {expandAnimation} from '../../../ui/animations/expand.ani';
import {GlobalConfigService} from '../../config/global-config.service';
import {checkKeyCombo} from '../../../util/check-key-combo';
import {distinctUntilChanged, map, switchMap, take, takeUntil} from 'rxjs/operators';
import {fadeAnimation} from '../../../ui/animations/fade.ani';
import {TaskAttachmentService} from '../task-attachment/task-attachment.service';
import {IssueService} from '../../issue/issue.service';
import {DialogEditTaskAttachmentComponent} from '../task-attachment/dialog-edit-attachment/dialog-edit-task-attachment.component';
import {swirlAnimation} from '../../../ui/animations/swirl-in-out.ani';
import {IS_TOUCH, IS_TOUCH_ONLY, isTouch} from '../../../util/is-touch';
import {DialogAddTaskReminderComponent} from '../dialog-add-task-reminder/dialog-add-task-reminder.component';
import {DialogEditTaskRepeatCfgComponent} from '../../task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component';
import {ProjectService} from '../../project/project.service';
import {Project} from '../../project/project.model';
import {T} from '../../../t.const';
import {MatMenuTrigger} from '@angular/material/menu';
import {AddTaskReminderInterface} from '../dialog-add-task-reminder/add-task-reminder-interface';
import {TODAY_TAG} from '../../tag/tag.const';
import {DialogEditTagsForTaskComponent} from '../../tag/dialog-edit-tags/dialog-edit-tags-for-task.component';
import {WorkContextService} from '../../work-context/work-context.service';
import {environment} from '../../../../environments/environment';
@Component({
selector: 'task',
templateUrl: './task.component.html',
styleUrls: ['./task.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [expandAnimation, fadeAnimation, swirlAnimation]
})
export class TaskComponent implements OnInit, OnDestroy, AfterViewInit {
task: TaskWithSubTasks;
@Input('task') set taskSet(v: TaskWithSubTasks) {
this.task = v;
this.progress = v && v.timeEstimate && (v.timeSpent / v.timeEstimate) * 100;
this.taskIdWithPrefix = 't-' + this.task.id;
this.isDone = v.isDone;
this._task$.next(v);
}
@Input() isBacklog: boolean;
T = T;
isDragOver: boolean;
isTouchOnly: boolean = IS_TOUCH_ONLY;
isLockPanLeft = false;
isLockPanRight = false;
isPreventPointerEventsWhilePanning = false;
isActionTriggered = false;
ShowSubTasksMode = ShowSubTasksMode;
contextMenuPosition = {x: '0px', y: '0px'};
progress: number;
isDev = !environment.production;
private _task$ = new ReplaySubject<TaskWithSubTasks>(1);
issueUrl$: Observable<string> = this._task$.pipe(
switchMap((v) => {
return (v.issueType && v.issueId && v.projectId)
? this._issueService.issueLink$(v.issueType, v.issueId, v.projectId)
: of(null);
}),
take(1),
);
moveToProjectList$: Observable<Project[]> = this._task$.pipe(
map(t => t.projectId),
distinctUntilChanged(),
switchMap((pid) => this._projectService.getProjectsWithoutId$(pid)),
);
@ViewChild('contentEditableOnClickEl', {static: true}) contentEditableOnClickEl: ElementRef;
@ViewChild('blockLeftEl') blockLeftElRef: ElementRef;
@ViewChild('blockRightEl') blockRightElRef: ElementRef;
@ViewChild('innerWrapperEl', {static: true}) innerWrapperElRef: ElementRef;
// only works because item comes first in dom
@ViewChild('contextMenuTriggerEl', {static: true, read: MatMenuTrigger}) contextMenu: MatMenuTrigger;
@ViewChild('projectMenuTriggerEl', {static: false, read: MatMenuTrigger}) projectMenuTrigger: MatMenuTrigger;
@HostBinding('tabindex') tabIndex = 1;
@HostBinding('class.isDone') isDone: boolean;
@HostBinding('id') taskIdWithPrefix: string;
// @see ngOnInit
@HostBinding('class.isCurrent') isCurrent: boolean;
@HostBinding('class.isSelected') isSelected: boolean;
TODAY_TAG_ID = TODAY_TAG.id;
private _dragEnterTarget: HTMLElement;
private _destroy$: Subject<boolean> = new Subject<boolean>();
private _currentPanTimeout: number;
constructor(
private readonly _taskService: TaskService,
private readonly _matDialog: MatDialog,
private readonly _configService: GlobalConfigService,
private readonly _issueService: IssueService,
private readonly _attachmentService: TaskAttachmentService,
private readonly _elementRef: ElementRef,
private readonly _renderer: Renderer2,
private readonly _cd: ChangeDetectorRef,
private readonly _projectService: ProjectService,
public readonly workContextService: WorkContextService,
) {
}
// methods come last
@HostListener('keydown', ['$event']) onKeyDown(ev: KeyboardEvent) {
this._handleKeyboardShortcuts(ev);
}
// @HostListener('focus', ['$event']) onFocus(ev: Event) {
// if (this._currentFocusId !== this.task.id && ev.target === this._elementRef.nativeElement) {
// this._taskService.focusTask(this.task.id);
// this._currentFocusId = this.task.id;
// }
// }
//
// @HostListener('blur', ['$event']) onBlur(ev: Event) {
// // console.log('BLUR', this._currentFocusId, this.task.id);
//
// // @TODO replace: hacky way to wait for last update
// setTimeout(() => {
// if (this._currentFocusId === this.task.id) {
// this._taskService.focusTask(null);
// this._currentFocusId = null;
// }
// });
// }
@HostListener('dragenter', ['$event']) onDragEnter(ev: Event) {
this._dragEnterTarget = ev.target as HTMLElement;
ev.preventDefault();
ev.stopPropagation();
this.isDragOver = true;
}
@HostListener('dragleave', ['$event']) onDragLeave(ev: Event) {
if (this._dragEnterTarget === (ev.target as HTMLElement)) {
ev.preventDefault();
ev.stopPropagation();
this.isDragOver = false;
}
}
@HostListener('drop', ['$event']) onDrop(ev: Event) {
this._attachmentService.createFromDrop(ev, this.task.id);
ev.stopPropagation();
this.isDragOver = false;
}
ngOnInit() {
this._taskService.currentTaskId$
.pipe(takeUntil(this._destroy$))
.subscribe((id) => {
this.isCurrent = (this.task && id === this.task.id);
this._cd.markForCheck();
});
this._taskService.selectedTaskId$
.pipe(takeUntil(this._destroy$))
.subscribe((id) => {
this.isSelected = (this.task && id === this.task.id);
this._cd.markForCheck();
});
}
ngAfterViewInit() {
// this._taskService.focusTaskId$
// .pipe(
// takeUntil(this._destroy$),
// )
// .subscribe((id) => {
// if (id === this.task.id && document.activeElement !== this._elementRef.nativeElement) {
// this.focusSelfElement();
// }
// });
// hacky but relatively performant
if (this.task.parentId && Date.now() - 100 < this.task.created) {
setTimeout(() => {
this.focusTitleForEdit();
});
}
}
ngOnDestroy() {
this._destroy$.next(true);
this._destroy$.unsubscribe();
if (this._currentPanTimeout) {
window.clearTimeout(this._currentPanTimeout);
}
}
editReminder() {
if (this.task.repeatCfgId) {
return;
}
this._matDialog.open(DialogAddTaskReminderComponent, {
data: {task: this.task} as AddTaskReminderInterface
})
.afterClosed()
.pipe(takeUntil(this._destroy$))
.subscribe(() => this.focusSelf());
}
updateIssueData() {
this._issueService.refreshIssue(this.task, true, true);
}
editTaskRepeatCfg() {
this._matDialog.open(DialogEditTaskRepeatCfgComponent, {
data: {
task: this.task,
}
})
.afterClosed()
.pipe(takeUntil(this._destroy$))
.subscribe(() => this.focusSelf());
}
handleUpdateBtnClick() {
this._taskService.setSelectedId(this.task.id);
}
deleteTask() {
this._taskService.remove(this.task);
this.focusNext(true);
}
startTask() {
this._taskService.setCurrentId(this.task.id);
this.focusSelf();
}
pauseTask() {
this._taskService.pauseCurrent();
}
updateTaskTitleIfChanged(isChanged: boolean, newTitle: string) {
if (isChanged) {
this._taskService.update(this.task.id, {title: newTitle});
}
this.focusSelf();
}
estimateTime() {
this._matDialog.open(DialogTimeEstimateComponent, {
data: {task: this.task},
autoFocus: !isTouch(),
})
.afterClosed()
.pipe(takeUntil(this._destroy$))
.subscribe(() => this.focusSelf());
}
addAttachment() {
this._matDialog.open(DialogEditTaskAttachmentComponent, {
data: {},
})
.afterClosed()
.pipe(takeUntil(this._destroy$))
.subscribe(result => {
this.focusSelf();
if (result) {
this._attachmentService.addAttachment(this.task.id, result);
}
});
}
addSubTask() {
this._taskService.addSubTaskTo(this.task.parentId || this.task.id);
}
toggleTaskDone() {
if (this.task.parentId) {
this.focusNext(true);
}
this.task.isDone
? this._taskService.setUnDone(this.task.id)
: this._taskService.setDone(this.task.id);
}
showAdditionalInfos() {
this._taskService.setSelectedId(this.task.id);
this.focusSelf();
}
hideAdditionalInfos() {
this._taskService.setSelectedId(this.task.id);
this.focusSelf();
}
toggleShowAdditionalInfoOpen() {
this.isSelected
? this._taskService.setSelectedId(null)
: this._taskService.setSelectedId(this.task.id);
// this.focusSelf();
}
toggleShowAttachments() {
this._taskService.setSelectedId(this.task.id, TaskAdditionalInfoTargetPanel.Attachments);
this.focusSelf();
}
toggleSubTaskMode() {
this._taskService.toggleSubTaskMode(this.task.id, true, true);
this.focusSelf();
}
editTags() {
this._matDialog.open(DialogEditTagsForTaskComponent, {
data: {
task: this.task
}
})
.afterClosed()
.pipe(takeUntil(this._destroy$))
.subscribe(() => this.focusSelf());
}
addToMyDay() {
this.onTagsUpdated([TODAY_TAG.id, ...this.task.tagIds]);
}
removeFromMyDay() {
this.onTagsUpdated(this.task.tagIds.filter(tagId => tagId !== TODAY_TAG.id));
}
focusPrevious(isFocusReverseIfNotPossible = false) {
const taskEls = Array.from(document.querySelectorAll('task'));
const currentIndex = taskEls.findIndex(el => document.activeElement === el);
const prevEl = taskEls[currentIndex - 1] as HTMLElement;
if (prevEl) {
prevEl.focus();
// hacky but works
setTimeout(() => {
if (document.activeElement !== prevEl) {
prevEl.focus();
}
});
} else if (isFocusReverseIfNotPossible) {
this.focusNext();
}
}
focusNext(isFocusReverseIfNotPossible = false) {
const taskEls = Array.from(document.querySelectorAll('task'));
const currentIndex = taskEls.findIndex(el => document.activeElement === el);
const nextEl = taskEls[currentIndex + 1] as HTMLElement;
if (nextEl) {
nextEl.focus();
// hacky but works
setTimeout(() => {
if (document.activeElement !== nextEl) {
nextEl.focus();
}
});
} else if (isFocusReverseIfNotPossible) {
this.focusPrevious();
}
}
focusSelf() {
this.focusSelfElement();
// this._taskService.focusTask(this.task.id);
}
focusSelfElement() {
this._elementRef.nativeElement.focus();
}
focusTitleForEdit() {
this.contentEditableOnClickEl.nativeElement.focus();
}
openContextMenu(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
this.contentEditableOnClickEl.nativeElement.blur();
this.contextMenuPosition.x = event.clientX + 'px';
this.contextMenuPosition.y = event.clientY + 'px';
this.contextMenu.openMenu();
}
onTagsUpdated(tagIds: string[]) {
this._taskService.updateTags(this.task, tagIds, this.task.tagIds);
}
onPanStart(ev) {
if (!IS_TOUCH) {
return;
}
this._resetAfterPan();
if (
(ev.target.className.indexOf && ev.target.className.indexOf('drag-handle') > -1)
|| Math.abs(ev.deltaY) > Math.abs(ev.deltaX)
|| document.activeElement === this.contentEditableOnClickEl.nativeElement
|| ev.isFinal
) {
return;
}
if (ev.deltaX > 0) {
this.isLockPanRight = true;
} else if (ev.deltaX < 0) {
this.isLockPanLeft = true;
}
}
onPanEnd() {
if (!IS_TOUCH || !this.isLockPanLeft && !this.isLockPanRight) {
return;
}
this.isPreventPointerEventsWhilePanning = false;
this._renderer.removeStyle(this.blockLeftElRef.nativeElement, 'transition');
this._renderer.removeStyle(this.blockRightElRef.nativeElement, 'transition');
if (this._currentPanTimeout) {
window.clearTimeout(this._currentPanTimeout);
}
if (this.isActionTriggered) {
if (this.isLockPanLeft) {
this._renderer.setStyle(this.blockRightElRef.nativeElement, 'transform', `scaleX(1)`);
this._currentPanTimeout = window.setTimeout(() => {
if (this.workContextService.isToday) {
if (this.task.repeatCfgId) {
this.editTaskRepeatCfg();
} else {
this.editReminder();
}
} else {
if (this.task.parentId) {
// NOTHING
} else {
if (this.task.tagIds.includes(TODAY_TAG.id)) {
this.removeFromMyDay();
} else {
this.addToMyDay();
}
}
}
this._resetAfterPan();
}, 100);
} else if (this.isLockPanRight) {
this._renderer.setStyle(this.blockLeftElRef.nativeElement, 'transform', `scaleX(1)`);
this._currentPanTimeout = window.setTimeout(() => {
this.toggleTaskDone();
this._resetAfterPan();
}, 100);
}
} else {
this._resetAfterPan();
}
}
onPanLeft(ev) {
this._handlePan(ev);
}
onPanRight(ev) {
this._handlePan(ev);
}
moveTaskToProject(projectId: string) {
this._taskService.moveToProject(this.task, projectId);
}
moveToBacklog() {
this._taskService.moveToBacklog(this.task.id);
}
moveToToday() {
this._taskService.moveToToday(this.task.id);
}
trackByProjectId(i: number, project: Project) {
return project.id;
}
private _handlePan(ev) {
if (!IS_TOUCH
|| !this.isLockPanLeft && !this.isLockPanRight
|| ev.eventType === 8) {
return;
}
const targetRef = this.isLockPanRight
? this.blockLeftElRef
: this.blockRightElRef;
const MAGIC_FACTOR = 2;
this.isPreventPointerEventsWhilePanning = true;
// this.contentEditableOnClickEl.nativeElement.blur();
if (targetRef) {
let scale = ev.deltaX / this._elementRef.nativeElement.offsetWidth * MAGIC_FACTOR;
scale = this.isLockPanLeft ? scale * -1 : scale;
scale = Math.min(1, Math.max(0, scale));
if (scale > 0.5) {
this.isActionTriggered = true;
this._renderer.addClass(targetRef.nativeElement, 'isActive');
} else {
this.isActionTriggered = false;
this._renderer.removeClass(targetRef.nativeElement, 'isActive');
}
const moveBy = this.isLockPanLeft ? ev.deltaX * -1 : ev.deltaX;
this._renderer.setStyle(targetRef.nativeElement, 'width', `${moveBy}px`);
this._renderer.setStyle(targetRef.nativeElement, 'transition', `none`);
this._renderer.setStyle(this.innerWrapperElRef.nativeElement, 'transform', `translateX(${ev.deltaX}px`);
}
}
private _resetAfterPan() {
this.isPreventPointerEventsWhilePanning = false;
this.isActionTriggered = false;
this.isLockPanLeft = false;
this.isLockPanRight = false;
const scale = 0;
// this._renderer.setStyle(this.blockLeftEl.nativeElement, 'transform', `scaleX(${scale})`);
// this._renderer.setStyle(this.blockRightEl.nativeElement, 'transform', `scaleX(${scale})`);
this._renderer.removeClass(this.blockLeftElRef.nativeElement, 'isActive');
this._renderer.removeClass(this.blockRightElRef.nativeElement, 'isActive');
this._renderer.setStyle(this.innerWrapperElRef.nativeElement, 'transform', ``);
}
private _handleKeyboardShortcuts(ev: KeyboardEvent) {
if (ev.target !== this._elementRef.nativeElement) {
return;
}
const keys = this._configService.cfg.keyboard;
const isShiftOrCtrlPressed = (ev.shiftKey === true || ev.ctrlKey === true);
if (checkKeyCombo(ev, keys.taskEditTitle) || ev.key === 'Enter') {
this.focusTitleForEdit();
// prevent blur
ev.preventDefault();
}
if (checkKeyCombo(ev, keys.taskToggleAdditionalInfoOpen)) {
this.toggleShowAdditionalInfoOpen();
}
if (checkKeyCombo(ev, keys.taskOpenEstimationDialog)) {
this.estimateTime();
}
if (checkKeyCombo(ev, keys.taskSchedule)) {
this.editReminder();
}
if (checkKeyCombo(ev, keys.taskToggleDone)) {
this.toggleTaskDone();
}
if (checkKeyCombo(ev, keys.taskAddSubTask)) {
this.addSubTask();
}
if (checkKeyCombo(ev, keys.taskMoveToProject)) {
this.projectMenuTrigger.openMenu();
}
if (checkKeyCombo(ev, keys.togglePlay)) {
if (this.isCurrent) {
this.pauseTask();
} else {
this.startTask();
}
}
if (checkKeyCombo(ev, keys.taskDelete)) {
this.deleteTask();
}
if (checkKeyCombo(ev, keys.moveToBacklog)) {
if (!this.task.parentId) {
this.focusPrevious(true);
this._taskService.moveToBacklog(this.task.id);
}
}
if (checkKeyCombo(ev, keys.moveToTodaysTasks)) {
if (!this.task.parentId) {
this.focusNext(true);
this._taskService.moveToToday(this.task.id);
}
}
// move focus up
if ((!isShiftOrCtrlPressed && ev.key === 'ArrowUp') || checkKeyCombo(ev, keys.selectPreviousTask)) {
ev.preventDefault();
this.focusPrevious();
}
// move focus down
if ((!isShiftOrCtrlPressed && ev.key === 'ArrowDown') || checkKeyCombo(ev, keys.selectNextTask)) {
ev.preventDefault();
this.focusNext();
}
// collapse sub tasks
if ((ev.key === 'ArrowLeft') || checkKeyCombo(ev, keys.collapseSubTasks)) {
const hasSubTasks = this.task.subTasks && this.task.subTasks.length > 0;
if (this.isSelected) {
this.hideAdditionalInfos();
} else if (hasSubTasks && this.task._showSubTasksMode !== ShowSubTasksMode.HideAll) {
this._taskService.toggleSubTaskMode(this.task.id, true, false);
// TODO find a solution
// } else if (this.task.parentId) {
// this._taskService.focusTask(this.task.parentId);
} else {
this.focusPrevious();
}
}
// expand sub tasks
if ((ev.key === 'ArrowRight') || checkKeyCombo(ev, keys.expandSubTasks)) {
const hasSubTasks = this.task.subTasks && this.task.subTasks.length > 0;
if (hasSubTasks && this.task._showSubTasksMode !== ShowSubTasksMode.Show) {
this._taskService.toggleSubTaskMode(this.task.id, false, false);
} else if (!this.isSelected) {
this.showAdditionalInfos();
} else {
this.focusNext();
}
}
// moving items
// move task up
if (checkKeyCombo(ev, keys.moveTaskUp)) {
this._taskService.moveUp(this.task.id, this.task.parentId, this.isBacklog);
ev.stopPropagation();
ev.preventDefault();
// timeout required to let changes take place @TODO hacky
setTimeout(this.focusSelf.bind(this));
}
if (checkKeyCombo(ev, keys.moveTaskDown)) {
this._taskService.moveDown(this.task.id, this.task.parentId, this.isBacklog);
ev.stopPropagation();
ev.preventDefault();
}
}
}