super-productivity/src/app/features/tasks/task/task.component.ts
2021-06-15 21:29:45 +02:00

812 lines
24 KiB
TypeScript

import {
AfterViewInit,
Attribute,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
HostBinding,
HostListener,
Input,
OnDestroy,
OnInit,
Renderer2,
ViewChild,
} from '@angular/core';
import { TaskService } from '../task.service';
import { EMPTY, 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_ONLY, isTouchOnly } 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';
import { throttle } from 'helpful-decorators';
import { ActivatedRoute } from '@angular/router';
@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 {
@Input() isBacklog: boolean = false;
isShowParentTitle: boolean = !!this.showParentTitle;
task!: TaskWithSubTasks;
T: typeof T = T;
IS_TOUCH_ONLY: boolean = IS_TOUCH_ONLY;
isDragOver: boolean = false;
isLockPanLeft: boolean = false;
isLockPanRight: boolean = false;
isPreventPointerEventsWhilePanning: boolean = false;
isActionTriggered: boolean = false;
ShowSubTasksMode: typeof ShowSubTasksMode = ShowSubTasksMode;
contextMenuPosition: { x: string; y: string } = { x: '0px', y: '0px' };
progress: number = 0;
isDev: boolean = !(environment.production || environment.stage);
@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: number = 1;
@HostBinding('class.isDone') isDone: boolean = false;
@HostBinding('id') taskIdWithPrefix: string = 'NO';
// @see ngOnInit
@HostBinding('class.isCurrent') isCurrent: boolean = false;
@HostBinding('class.isSelected') isSelected: boolean = false;
TODAY_TAG_ID: string = TODAY_TAG.id;
private _task$: ReplaySubject<TaskWithSubTasks> = new ReplaySubject(1);
issueUrl$: Observable<string | null> = 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)),
);
parentTitle$: Observable<string> = this.isShowParentTitle
? this._task$.pipe(
take(1),
switchMap((task) => this._taskService.getByIdLive$(task.parentId as string)),
map((task) => task.title),
)
: EMPTY;
projectColor$: Observable<string> = this.isShowParentTitle
? this._task$.pipe(
take(1),
switchMap((task) =>
task.projectId ? this._projectService.getByIdOnce$(task.projectId) : EMPTY,
),
map((project) => project.theme.primary),
)
: EMPTY;
isFirstLineHover: boolean = false;
private _dragEnterTarget?: HTMLElement;
private _destroy$: Subject<boolean> = new Subject<boolean>();
private _currentPanTimeout?: number;
private _isTaskDeleteTriggered: boolean = false;
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,
private _route: ActivatedRoute,
@Attribute('showParentTitle') private showParentTitle: string,
) {}
@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);
}
// 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: DragEvent) {
this._dragEnterTarget = ev.target as HTMLElement;
ev.preventDefault();
ev.stopPropagation();
this.isDragOver = true;
}
@HostListener('dragleave', ['$event']) onDragLeave(ev: DragEvent) {
if (this._dragEnterTarget === (ev.target as HTMLElement)) {
ev.preventDefault();
ev.stopPropagation();
this.isDragOver = false;
}
}
@HostListener('drop', ['$event']) onDrop(ev: DragEvent) {
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();
});
} else {
this._route.queryParams.subscribe((params) => {
window.setTimeout(() => {
if (params.highlightItem === this.task.id) {
this.focusSelfElement();
}
}, 200);
});
}
}
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(isClick: boolean = false) {
// NOTE: prevents attempts to delete the same task multiple times
if (this._isTaskDeleteTriggered) {
return;
}
this._isTaskDeleteTriggered = true;
this._taskService.remove(this.task);
// NOTE: in case we want the focus behaviour on click we could use:
// this.focusSelf();
if (!isClick) {
this.focusNext(true);
}
}
startTask() {
this._taskService.setCurrentId(this.task.id);
this.focusSelf();
}
pauseTask() {
this._taskService.pauseCurrent();
}
updateTaskTitleIfChanged({
isChanged,
newVal,
}: {
isChanged: boolean;
newVal: string;
$taskEl: HTMLElement | null;
event: Event;
}) {
if (isChanged) {
this._taskService.update(this.task.id, { title: newVal });
}
this.focusSelf();
}
estimateTime() {
this._matDialog
.open(DialogTimeEstimateComponent, {
data: { task: this.task },
autoFocus: !isTouchOnly(),
})
.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);
}
@throttle(200, { leading: true, trailing: false })
toggleDoneKeyboard() {
this.toggleTaskDone();
if (!this.task.parentId) {
this.focusNext(true);
}
}
toggleTaskDone() {
if (this.task.parentId) {
this.focusNext(true);
}
if (this.task.isDone) {
this._taskService.setUnDone(this.task.id);
} else {
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() {
if (this.isSelected) {
this._taskService.setSelectedId(null);
} else {
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._taskService.addTodayTag(this.task);
}
removeFromMyDay() {
this.onTagsUpdated(this.task.tagIds.filter((tagId) => tagId !== TODAY_TAG.id));
}
convertToMainTask() {
this._taskService.convertToMainTask(this.task);
}
focusPrevious(isFocusReverseIfNotPossible: boolean = false) {
if (IS_TOUCH_ONLY) {
return;
}
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: boolean = false) {
if (IS_TOUCH_ONLY) {
return;
}
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() {
if (IS_TOUCH_ONLY) {
return;
}
this.focusSelfElement();
// this._taskService.focusTask(this.task.id);
}
focusSelfElement() {
this._elementRef.nativeElement.focus();
}
focusTitleForEdit() {
if (!this.contentEditableOnClickEl) {
throw new Error('No el');
}
this.contentEditableOnClickEl.nativeElement.focus();
}
openContextMenu(event: MouseEvent) {
if (!this.contentEditableOnClickEl || !this.contextMenu) {
throw new Error('No el');
}
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: any) {
if (!IS_TOUCH_ONLY) {
return;
}
if (!this.contentEditableOnClickEl) {
throw new Error('No el');
}
this._resetAfterPan();
const targetEl: HTMLElement = ev.target as HTMLElement;
if (
(targetEl.className.indexOf && targetEl.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_ONLY || (!this.isLockPanLeft && !this.isLockPanRight)) {
return;
}
if (!this.blockLeftElRef || !this.blockRightElRef) {
throw new Error('No el');
}
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: any) {
this._handlePan(ev);
}
onPanRight(ev: any) {
this._handlePan(ev);
}
moveTaskToProject(projectId: string) {
this._taskService.moveToProject(this.task, projectId);
}
moveToBacklog() {
this._taskService.moveToBacklog(this.task.id);
if (this.task.tagIds.includes(TODAY_TAG.id)) {
this.removeFromMyDay();
}
}
moveToToday() {
this._taskService.moveToProjectTodayList(this.task.id);
}
trackByProjectId(i: number, project: Project) {
return project.id;
}
private _handlePan(ev: any) {
if (
!IS_TOUCH_ONLY ||
(!this.isLockPanLeft && !this.isLockPanRight) ||
ev.eventType === 8
) {
return;
}
if (!this.innerWrapperElRef) {
throw new Error('No el');
}
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() {
if (
!this.contentEditableOnClickEl ||
!this.blockLeftElRef ||
!this.blockRightElRef ||
!this.innerWrapperElRef
) {
throw new Error('No el');
}
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 cfg = this._configService.cfg;
if (!cfg) {
throw new Error();
}
const keys = cfg.keyboard;
const isShiftOrCtrlPressed = ev.shiftKey || ev.ctrlKey;
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.toggleDoneKeyboard();
}
if (checkKeyCombo(ev, keys.taskAddSubTask)) {
this.addSubTask();
}
if (checkKeyCombo(ev, keys.taskMoveToProject)) {
if (!this.projectMenuTrigger) {
throw new Error('No el');
}
this.projectMenuTrigger.openMenu();
}
if (checkKeyCombo(ev, keys.taskOpenContextMenu)) {
if (!this.contextMenu) {
throw new Error('No el');
}
this.contextMenu.openMenu();
}
if (checkKeyCombo(ev, keys.togglePlay)) {
if (this.isCurrent) {
this.pauseTask();
} else {
this.startTask();
}
}
if (checkKeyCombo(ev, keys.taskEditTags)) {
this.editTags();
}
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.moveToProjectTodayList(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 as any).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 as any).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();
}
}
}