mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
* fix(a11y): name icon-only buttons, add keyboard access, fix task tabindex Addresses the three groups from #8826: - Add translated aria-labels to unnamed icon-only buttons (search clear, note/side-nav/feature more_vert triggers, focus-mode close, board edit, track-time dialog suffix buttons); simple-counter buttons use the counter title as their accessible name. Adds one new global key G.MORE_ACTIONS. - Make mouse-only controls keyboard-accessible (role=button, tabindex=0, Enter/Space handlers, aria-expanded where they toggle): formly-collapsible header, worklog week day rows, task detail panel created/completed date editors, and the task time/estimate cell (attributes conditional so parents with subtasks gain no tab stop). - Change task host tabindex from 1 to 0 so tasks no longer jump ahead of all other page content in tab order (WCAG 2.4.3). Fixes #8826 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(a11y): import TranslatePipe in focus-mode-overlay FocusModeOverlayComponent's template uses the translate pipe (added aria-label) but the standalone component did not import TranslatePipe, breaking the production AOT build (NG8004). Add it to imports, matching every other component touched in this change. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
1414 lines
47 KiB
TypeScript
1414 lines
47 KiB
TypeScript
import {
|
|
AfterViewInit,
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
computed,
|
|
DestroyRef,
|
|
effect,
|
|
ElementRef,
|
|
forwardRef,
|
|
HostListener,
|
|
inject,
|
|
input,
|
|
OnDestroy,
|
|
signal,
|
|
untracked,
|
|
viewChild,
|
|
} from '@angular/core';
|
|
import { TaskService } from '../task.service';
|
|
import { EMPTY, forkJoin, Subscription } from 'rxjs';
|
|
import {
|
|
HideSubTasksMode,
|
|
SubmitTrigger,
|
|
TaskCopy,
|
|
TaskDetailTargetPanel,
|
|
TaskWithSubTasks,
|
|
} from '../task.model';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
import { DialogTimeEstimateComponent } from '../dialog-time-estimate/dialog-time-estimate.component';
|
|
import {
|
|
expandFadeAnimation,
|
|
expandInOnlyAnimation,
|
|
} from '../../../ui/animations/expand.ani';
|
|
import {
|
|
ChecklistProgress,
|
|
getChecklistProgress,
|
|
} from '../../markdown-checklist/get-checklist-progress';
|
|
import { GlobalConfigService } from '../../config/global-config.service';
|
|
import { concatMap, first, tap } from 'rxjs/operators';
|
|
import { DoneToggleComponent } from '../../../ui/done-toggle/done-toggle.component';
|
|
import { SwipeBlockComponent } from '../../../ui/swipe-block/swipe-block.component';
|
|
import {
|
|
CollapsibleComponent,
|
|
GROUP_NAV_SELECTOR,
|
|
} from '../../../ui/collapsible/collapsible.component';
|
|
import { findAdjacentFocusable } from '../../../util/find-adjacent-focusable';
|
|
import { TaskAttachmentService } from '../task-attachment/task-attachment.service';
|
|
import { DialogEditTaskAttachmentComponent } from '../task-attachment/dialog-edit-attachment/dialog-edit-task-attachment.component';
|
|
import { ProjectService } from '../../project/project.service';
|
|
import { Project } from '../../project/project.model';
|
|
import { _MISSING_PROJECT_, DEFAULT_PROJECT_ICON } from '../../project/project.const';
|
|
import { T } from '../../../t.const';
|
|
import {
|
|
MatMenu,
|
|
MatMenuContent,
|
|
MatMenuItem,
|
|
MatMenuTrigger,
|
|
} from '@angular/material/menu';
|
|
import { WorkContextService } from '../../work-context/work-context.service';
|
|
import { throttle } from '../../../util/decorators';
|
|
import { TaskRepeatCfgService } from '../../task-repeat-cfg/task-repeat-cfg.service';
|
|
import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component';
|
|
import { openFullscreenMarkdownDialog } from '../../../ui/dialog-fullscreen-markdown/open-fullscreen-markdown-dialog';
|
|
import { Location } from '@angular/common';
|
|
import { Update } from '@ngrx/entity';
|
|
import { DateAdapter } from '@angular/material/core';
|
|
import { getDbDateStr, isDBDateStr } from '../../../util/get-db-date-str';
|
|
import { combineDateAndTime } from '../../../util/combine-date-and-time';
|
|
import { getNextWeekDayOffset } from '../../../util/get-next-week-day-offset';
|
|
import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const';
|
|
import { DateService } from '../../../core/date/date.service';
|
|
import { isTouchActive } from '../../../util/input-intent';
|
|
import { IS_HYBRID_DEVICE } from '../../../util/is-mouse-primary';
|
|
import { DRAG_DELAY_FOR_TOUCH } from '../../../app.constants';
|
|
import {
|
|
EMPTY_KEYBOARD_CONFIG,
|
|
KeyboardConfig,
|
|
keyboardConfigOrEmpty,
|
|
} from '@sp/keyboard-config';
|
|
import { DialogScheduleTaskComponent } from '../../planner/dialog-schedule-task/dialog-schedule-task.component';
|
|
import { PlannerActions } from '../../planner/store/planner.actions';
|
|
import { PlannerService } from '../../planner/planner.service';
|
|
import { DialogDeadlineComponent } from '../dialog-deadline/dialog-deadline.component';
|
|
import { isDeadlineOverdue as isDeadlineOverdueFn } from '../util/is-deadline-overdue';
|
|
import { isDeadlineApproaching as isDeadlineApproachingFn } from '../util/is-deadline-approaching';
|
|
import { TaskContextMenuComponent } from '../task-context-menu/task-context-menu.component';
|
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
import { ICAL_TYPE, PLAINSPACE_TYPE } from '../../issue/issue.const';
|
|
import { TaskTitleComponent } from '../../../ui/task-title/task-title.component';
|
|
import { MatIcon } from '@angular/material/icon';
|
|
import { MatIconButton, MatMiniFabButton } from '@angular/material/button';
|
|
import { TaskHoverControlsComponent } from './task-hover-controls/task-hover-controls.component';
|
|
import { ProgressBarComponent } from '../../../ui/progress-bar/progress-bar.component';
|
|
import { TaskListComponent } from '../task-list/task-list.component';
|
|
import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe';
|
|
import { ShortPlannedAtPipe } from '../../../ui/pipes/short-planned-at.pipe';
|
|
import { LocalDateStrPipe } from '../../../ui/pipes/local-date-str.pipe';
|
|
import { LocaleDatePipe } from '../../../ui/pipes/locale-date.pipe';
|
|
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
|
import { SubTaskTotalTimeSpentPipe } from '../pipes/sub-task-total-time-spent.pipe';
|
|
import { TagListComponent } from '../../tag/tag-list/tag-list.component';
|
|
import { TagToggleMenuListComponent } from '../../tag/tag-toggle-menu-list/tag-toggle-menu-list.component';
|
|
import { Store } from '@ngrx/store';
|
|
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
|
import { environment } from '../../../../environments/environment';
|
|
import { TODAY_TAG } from '../../tag/tag.const';
|
|
import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service';
|
|
import { TaskLog } from '../../../core/log';
|
|
import { LayoutService } from '../../../core-ui/layout/layout.service';
|
|
import { TaskFocusService } from '../task-focus.service';
|
|
import { MatTooltip } from '@angular/material/tooltip';
|
|
import { millisecondsDiffToRemindOption } from '../util/remind-option-to-milliseconds';
|
|
import { MenuTreeService } from '../../menu-tree/menu-tree.service';
|
|
import { SelectOptionRowComponent } from '../../../ui/select-option-row/select-option-row.component';
|
|
import { SnackService } from '../../../core/snack/snack.service';
|
|
import {
|
|
AddSubtaskInputComponent,
|
|
AddSubtaskInputCloseReason,
|
|
} from '../add-subtask-input/add-subtask-input.component';
|
|
import { AddSubtaskInputService } from '../add-subtask-input/add-subtask-input.service';
|
|
|
|
@Component({
|
|
selector: 'task',
|
|
templateUrl: './task.component.html',
|
|
styleUrls: ['./task.component.scss'],
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
animations: [expandInOnlyAnimation, expandFadeAnimation],
|
|
/* eslint-disable @typescript-eslint/naming-convention*/
|
|
host: {
|
|
'[id]': 'taskIdWithPrefix()',
|
|
'[attr.data-task-id]': 'task().id',
|
|
'[tabindex]': '0',
|
|
'[class.isDone]': 'task().isDone',
|
|
'[class.isCurrent]': 'isCurrent()',
|
|
'[class.isSelected]': 'isSelected()',
|
|
'[class.hasNoSubTasks]': 'task().subTaskIds.length === 0',
|
|
'[class.isDragReady]': 'isDragReady()',
|
|
'[class.isOverdue]': 'isOverdue()',
|
|
'(contextmenu)': 'onHostContextMenu($event)',
|
|
},
|
|
imports: [
|
|
MatIcon,
|
|
MatMenuTrigger,
|
|
MatIconButton,
|
|
TaskTitleComponent,
|
|
TaskHoverControlsComponent,
|
|
ProgressBarComponent,
|
|
MatMiniFabButton,
|
|
forwardRef(() => TaskListComponent),
|
|
TaskContextMenuComponent,
|
|
MatMenu,
|
|
MatMenuContent,
|
|
MatMenuItem,
|
|
MsToStringPipe,
|
|
LocalDateStrPipe,
|
|
TranslatePipe,
|
|
MatTooltip,
|
|
SubTaskTotalTimeSpentPipe,
|
|
TagListComponent,
|
|
ShortPlannedAtPipe,
|
|
TagToggleMenuListComponent,
|
|
DoneToggleComponent,
|
|
SwipeBlockComponent,
|
|
SelectOptionRowComponent,
|
|
AddSubtaskInputComponent,
|
|
],
|
|
})
|
|
export class TaskComponent implements OnDestroy, AfterViewInit {
|
|
private readonly _taskService = inject(TaskService);
|
|
private readonly _taskRepeatCfgService = inject(TaskRepeatCfgService);
|
|
private readonly _matDialog = inject(MatDialog);
|
|
private readonly _location = inject(Location);
|
|
private readonly _configService = inject(GlobalConfigService);
|
|
private readonly _attachmentService = inject(TaskAttachmentService);
|
|
private readonly _elementRef = inject(ElementRef);
|
|
private readonly _dateAdapter = inject(DateAdapter);
|
|
private readonly _store = inject(Store);
|
|
private readonly _projectService = inject(ProjectService);
|
|
private readonly _taskFocusService = inject(TaskFocusService);
|
|
private readonly _dateService = inject(DateService);
|
|
private readonly _destroyRef = inject(DestroyRef);
|
|
private readonly _menuTreeService = inject(MenuTreeService);
|
|
private readonly _snackService = inject(SnackService);
|
|
private readonly _translateService = inject(TranslateService);
|
|
private readonly _datePipe = inject(LocaleDatePipe);
|
|
private readonly _plannerService = inject(PlannerService);
|
|
private readonly _addSubtaskInputService = inject(AddSubtaskInputService);
|
|
|
|
readonly workContextService = inject(WorkContextService);
|
|
readonly layoutService = inject(LayoutService);
|
|
readonly globalTrackingIntervalService = inject(GlobalTrackingIntervalService);
|
|
|
|
task = input.required<TaskWithSubTasks>();
|
|
isBacklog = input<boolean>(false);
|
|
isInSubTaskList = input<boolean>(false);
|
|
showDoneAnimation = signal(false);
|
|
showUndoneAnimation = signal(false);
|
|
|
|
// Use shared signals from services to avoid creating 600+ subscriptions on initial render
|
|
isCurrent = computed(() => this._taskService.currentTaskId() === this.task().id);
|
|
isSelected = computed(() => this._taskService.selectedTaskId() === this.task().id);
|
|
isShowCloseButton = computed(() => {
|
|
// Only show close button when task is selected AND not on mobile (bottom panel)
|
|
return this.isSelected() && !this.layoutService.isXs();
|
|
});
|
|
|
|
// Determines if the toggle detail panel button should be visible
|
|
isShowToggleButton = computed(() => {
|
|
const t = this.task();
|
|
// A checklist already shows its own badge (which opens the notes panel), so
|
|
// suppress the redundant plain 'chat' notes indicator. The button is kept for
|
|
// the 'close' (panel open) and 'update' (issue changed) states.
|
|
if (this.checklistProgress() && this.toggleButtonIcon() === 'chat') {
|
|
return false;
|
|
}
|
|
// iCal and Plainspace mirror all their issue data into native task fields, so
|
|
// their detail panel only repeats what's already on the task. Don't surface the
|
|
// button just because they carry an issueId — only for real notes, a remote
|
|
// update (the 'update' icon), or when the panel is open.
|
|
const isMirroredIssueType =
|
|
t.issueType === ICAL_TYPE || t.issueType === PLAINSPACE_TYPE;
|
|
return (
|
|
!!t.notes ||
|
|
(!!t.issueId && !isMirroredIssueType) ||
|
|
!!t.issueWasUpdated ||
|
|
this.isShowCloseButton()
|
|
);
|
|
});
|
|
|
|
// Determines which icon to show in the toggle button
|
|
toggleButtonIcon = computed((): 'chat' | 'close' | 'update' => {
|
|
const t = this.task();
|
|
if (t.issueWasUpdated) return 'update';
|
|
if (this.isShowCloseButton()) return 'close';
|
|
return 'chat';
|
|
});
|
|
|
|
isTodayListActive = computed(() => this.workContextService.isTodayListSignal());
|
|
taskIdWithPrefix = computed(() => 't-' + this.task().id);
|
|
isRepeatTaskCreatedToday = computed(
|
|
() => !!(this.task().repeatCfgId && this._dateService.isToday(this.task().created)),
|
|
);
|
|
isOverdue = computed(() => {
|
|
const t = this.task();
|
|
const todayStr = this.globalTrackingIntervalService.todayDateStr();
|
|
return (
|
|
!t.isDone &&
|
|
((t.dueWithTime &&
|
|
!this._dateService.isToday(t.dueWithTime) &&
|
|
t.dueWithTime < Date.now()) ||
|
|
// Note: String comparison works correctly here because dueDay is in YYYY-MM-DD format
|
|
// which is lexicographically sortable. This avoids timezone conversion issues that occur
|
|
// when creating Date objects from date strings.
|
|
// Guard: only compare if dueDay is a valid YYYY-MM-DD string to avoid corrupted data
|
|
// producing false overdue results (see #6908)
|
|
(t.dueDay &&
|
|
isDBDateStr(t.dueDay) &&
|
|
t.dueDay !== todayStr &&
|
|
t.dueDay < todayStr))
|
|
);
|
|
});
|
|
isScheduledToday = computed(() => {
|
|
const t = this.task();
|
|
const todayStr = this.globalTrackingIntervalService.todayDateStr();
|
|
return (
|
|
(t.dueWithTime && this._dateService.isToday(t.dueWithTime)) ||
|
|
(t.dueDay && t.dueDay === todayStr)
|
|
);
|
|
});
|
|
hasTimeConflict = computed(() => {
|
|
const task = this.task();
|
|
return (
|
|
typeof task.dueWithTime === 'number' &&
|
|
this._taskService.timeConflictTaskIds().has(task.id)
|
|
);
|
|
});
|
|
|
|
isShowDueDayBtn = computed(() => {
|
|
const dueDay = this.task().dueDay;
|
|
return (
|
|
dueDay &&
|
|
isDBDateStr(dueDay) &&
|
|
(!this.isTodayListActive() ||
|
|
this.isOverdue() ||
|
|
dueDay !== this.globalTrackingIntervalService.todayDateStr())
|
|
);
|
|
});
|
|
|
|
progress = computed<number>(() => {
|
|
const t = this.task();
|
|
return (t.timeEstimate && (t.timeSpent / t.timeEstimate) * 100) || 0;
|
|
});
|
|
|
|
// Checklist progress derived from markdown checklist in task notes (null = no checklist)
|
|
checklistProgress = computed<ChecklistProgress | null>(() =>
|
|
getChecklistProgress(this.task().notes),
|
|
);
|
|
|
|
isShowRemoveFromToday = computed(() => {
|
|
return (
|
|
!this.isTodayListActive() &&
|
|
!this.task().isDone &&
|
|
this.task().dueDay === this.globalTrackingIntervalService.todayDateStr()
|
|
);
|
|
});
|
|
|
|
isShowAddToToday = computed(() => {
|
|
const task = this.task();
|
|
const todayStr = this.globalTrackingIntervalService.todayDateStr();
|
|
return this.isTodayListActive()
|
|
? (task.dueWithTime && !this._dateService.isToday(task.dueWithTime)) ||
|
|
(task.dueDay && task.dueDay !== todayStr)
|
|
: !this.isShowRemoveFromToday() &&
|
|
task.dueDay !== todayStr &&
|
|
(!task.dueWithTime || !this._dateService.isToday(task.dueWithTime));
|
|
});
|
|
|
|
isDeadlineOverdue = computed(() =>
|
|
isDeadlineOverdueFn(this.task(), this.globalTrackingIntervalService.todayDateStr()),
|
|
);
|
|
|
|
isDeadlineApproaching = computed(() =>
|
|
isDeadlineApproachingFn(
|
|
this.task(),
|
|
this.globalTrackingIntervalService.todayDateStr(),
|
|
),
|
|
);
|
|
|
|
hasDeadline = computed(() => {
|
|
const t = this.task();
|
|
return !!(t.deadlineDay || t.deadlineWithTime);
|
|
});
|
|
|
|
T: typeof T = T;
|
|
readonly DEFAULT_PROJECT_ICON = DEFAULT_PROJECT_ICON;
|
|
isTouchActive = isTouchActive;
|
|
isDragOver: boolean = false;
|
|
isDragReady = signal(false);
|
|
private _dragReadyTimeout: number | undefined;
|
|
private _doneAnimationTimeout: number | undefined;
|
|
private _touchListenerCleanups: (() => void)[] = [];
|
|
ShowSubTasksMode: typeof HideSubTasksMode = HideSubTasksMode;
|
|
isFirstLineHover: boolean = false;
|
|
_nextFocusTaskEl?: HTMLElement;
|
|
|
|
readonly taskTitleEditEl = viewChild<TaskTitleComponent>('taskTitleEditEl');
|
|
readonly projectMenuTrigger = viewChild('projectMenuTriggerEl', {
|
|
read: MatMenuTrigger,
|
|
});
|
|
readonly tagToggleMenuList = viewChild('tagToggleMenuList', {
|
|
read: TagToggleMenuListComponent,
|
|
});
|
|
readonly taskContextMenu = viewChild('taskContextMenu', {
|
|
read: TaskContextMenuComponent,
|
|
});
|
|
readonly addSubtaskInput = viewChild(AddSubtaskInputComponent);
|
|
readonly isAddSubtaskInputVisible = signal(false);
|
|
// Task the draft was opened from (may be a subtask), captured before focus
|
|
// moves into the input, so Escape can return focus there. See onAddSubtaskInputClosed.
|
|
private _subtaskInputOriginTaskId: string | null = null;
|
|
|
|
private readonly _addSubtaskInputRequestEffect = effect(() => {
|
|
const requestedParentId = this._addSubtaskInputService.openRequest();
|
|
if (requestedParentId === null || requestedParentId !== untracked(this.task).id) {
|
|
return;
|
|
}
|
|
|
|
window.setTimeout(() => {
|
|
// Consume the request so it isn't replayed (stealing focus) the next time
|
|
// this row is re-created with the same id, e.g. navigating away and back.
|
|
this._addSubtaskInputService.consume();
|
|
// Focus is still on the originating task here (the input isn't shown yet),
|
|
// so capture it now — once the input is focused, the parent row claims it.
|
|
this._subtaskInputOriginTaskId =
|
|
this._taskFocusService.focusedTaskId() ??
|
|
this._taskFocusService.lastFocusedTaskComponent()?.task().id ??
|
|
this.task().id;
|
|
const currentTask = this.task();
|
|
if (currentTask._hideSubTasksMode === HideSubTasksMode.HideAll) {
|
|
this._taskService.showSubTasks(currentTask.id);
|
|
}
|
|
this.isAddSubtaskInputVisible.set(true);
|
|
window.setTimeout(() => this.addSubtaskInput()?.focus());
|
|
});
|
|
});
|
|
|
|
// Lazy-loaded project list - only fetched when project menu opens
|
|
moveToProjectList = signal<Project[] | undefined>(undefined);
|
|
projectFolderMap = computed(() => this._menuTreeService.projectFolderMap());
|
|
private _loadedProjectListForProjectId: string | null | undefined;
|
|
private _moveToProjectListSub?: Subscription;
|
|
|
|
// Parent title derived directly from task data when available in subtask model.
|
|
// Falls back to a live store subscription only when needed (non-subtask-list with parentId).
|
|
private _parentTaskTitle = signal<string | null>(null);
|
|
parentTitle = computed(() => {
|
|
const t = this.task();
|
|
if (!t.parentId || this.isInSubTaskList()) {
|
|
return null;
|
|
}
|
|
return this._parentTaskTitle();
|
|
});
|
|
|
|
isProjectMenuLoaded = signal(false);
|
|
|
|
private _parentTitleEffect = effect((onCleanup) => {
|
|
const t = this.task();
|
|
const isInSubTaskList = this.isInSubTaskList();
|
|
if (!t.parentId || isInSubTaskList) {
|
|
this._parentTaskTitle.set(null);
|
|
return;
|
|
}
|
|
// Only subscribe when this task has a parentId and is NOT in a subtask list
|
|
const sub = this._taskService.getByIdLive$(t.parentId).subscribe((parent) => {
|
|
this._parentTaskTitle.set(parent?.title ?? null);
|
|
});
|
|
onCleanup(() => sub.unsubscribe());
|
|
});
|
|
|
|
private _dragEnterTarget?: HTMLElement;
|
|
private _doubleClickTimeout?: number;
|
|
private _isTaskDeleteTriggered = false;
|
|
isContextMenuLoaded = signal(false);
|
|
|
|
// methods come last
|
|
|
|
@HostListener('focusin', ['$event']) onFocus(ev: FocusEvent): void {
|
|
// focusin bubbles, so events from nested <task> elements (subtasks) reach
|
|
// every ancestor task host. Only the innermost task should claim focus.
|
|
if (!this._isInnermostTaskFor(ev.target)) {
|
|
return;
|
|
}
|
|
this._taskFocusService.focusedTaskId.set(this.task().id);
|
|
this._taskFocusService.lastFocusedTaskComponent.set(this);
|
|
|
|
// If detail panel is open for another task, update it to show this task (#6578)
|
|
// Skip if this task is inside the detail panel (e.g. subtask list)
|
|
if (this._elementRef.nativeElement.closest('task-detail-panel')) {
|
|
return;
|
|
}
|
|
// Skip when focus came from clicking the toggle-detail-panel button itself —
|
|
// the click handler owns that action; running both inverts the toggle (#7694).
|
|
if (ev.target instanceof Element && ev.target.closest('.show-additional-info-btn')) {
|
|
return;
|
|
}
|
|
const selectedTaskId = this._taskService.selectedTaskId();
|
|
if (selectedTaskId && selectedTaskId !== this.task().id) {
|
|
this._taskService.setSelectedId(this.task().id);
|
|
}
|
|
}
|
|
|
|
@HostListener('focusout', ['$event']) onBlur(ev: FocusEvent): void {
|
|
if (!this._isInnermostTaskFor(ev.target)) {
|
|
return;
|
|
}
|
|
if (
|
|
ev.relatedTarget instanceof Node &&
|
|
this._elementRef.nativeElement.contains(ev.relatedTarget)
|
|
) {
|
|
return;
|
|
}
|
|
this._taskFocusService.focusedTaskId.set(null);
|
|
}
|
|
|
|
private _isInnermostTaskFor(target: EventTarget | null): boolean {
|
|
return (
|
|
target instanceof Element &&
|
|
target.closest('task') === this._elementRef.nativeElement
|
|
);
|
|
}
|
|
|
|
@HostListener('dragenter', ['$event']) onDragEnter(ev: DragEvent): void {
|
|
this._dragEnterTarget = ev.target as HTMLElement;
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
this.isDragOver = true;
|
|
}
|
|
|
|
@HostListener('dragleave', ['$event']) onDragLeave(ev: DragEvent): void {
|
|
if (this._dragEnterTarget === (ev.target as HTMLElement)) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
this.isDragOver = false;
|
|
}
|
|
}
|
|
|
|
@HostListener('drop', ['$event']) onDrop(ev: DragEvent): void {
|
|
ev.preventDefault();
|
|
this.focusSelf();
|
|
this._attachmentService.createFromDrop(ev, this.task().id, true);
|
|
ev.stopPropagation();
|
|
this.isDragOver = false;
|
|
}
|
|
|
|
ngAfterViewInit(): void {
|
|
if (isTouchActive() || IS_HYBRID_DEVICE) {
|
|
const el = this._elementRef.nativeElement;
|
|
const onStart = (): void => this.onHostTouchStart();
|
|
const onEnd = (): void => this.onHostTouchEnd();
|
|
el.addEventListener('touchstart', onStart, { passive: true });
|
|
el.addEventListener('touchend', onEnd, { passive: true });
|
|
el.addEventListener('touchmove', onEnd, { passive: true });
|
|
this._touchListenerCleanups = [
|
|
() => el.removeEventListener('touchstart', onStart),
|
|
() => el.removeEventListener('touchend', onEnd),
|
|
() => el.removeEventListener('touchmove', onEnd),
|
|
];
|
|
}
|
|
|
|
// Dev-time sanity check: TODAY_TAG should NEVER be in task.tagIds (virtual tag pattern)
|
|
// Membership is determined by task.dueDay. See: docs/ai/today-tag-architecture.md
|
|
if (!environment.production) {
|
|
if (this.task().tagIds.includes(TODAY_TAG.id)) {
|
|
throw new Error('Task should not have TODAY_TAG in tagIds - it is a virtual tag');
|
|
}
|
|
}
|
|
|
|
// hacky but relatively performant
|
|
const t = this.task();
|
|
if (t.parentId && !t.title.length && Date.now() - 200 < t.created) {
|
|
setTimeout(() => {
|
|
// when there are multiple instances with the same task we should focus the last one, since it is the one in the
|
|
// task side panel
|
|
const otherTaskEl = document.querySelectorAll('#t-' + CSS.escape(t.id));
|
|
if (
|
|
otherTaskEl?.length <= 1 ||
|
|
Array.from(otherTaskEl).findIndex(
|
|
(item) => item === this._elementRef.nativeElement,
|
|
) ===
|
|
otherTaskEl.length - 1
|
|
) {
|
|
this.focusTitleForEdit();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
window.clearTimeout(this._doubleClickTimeout);
|
|
window.clearTimeout(this._dragReadyTimeout);
|
|
window.clearTimeout(this._doneAnimationTimeout);
|
|
this._touchListenerCleanups.forEach((fn) => fn());
|
|
this._moveToProjectListSub?.unsubscribe();
|
|
}
|
|
|
|
scheduleTask(): void {
|
|
this._storeNextFocusEl();
|
|
this._matDialog
|
|
.open(DialogScheduleTaskComponent, {
|
|
// we focus inside dialog instead
|
|
autoFocus: false,
|
|
data: { task: this.task() },
|
|
})
|
|
.afterClosed()
|
|
.subscribe((isPlanned) => {
|
|
this.focusSelfOrNextIfNotPossible();
|
|
});
|
|
}
|
|
|
|
openDeadlineDialog(): void {
|
|
this._storeNextFocusEl();
|
|
this._matDialog
|
|
.open(DialogDeadlineComponent, {
|
|
autoFocus: false,
|
|
data: { task: this.task() },
|
|
})
|
|
.afterClosed()
|
|
.subscribe(() => {
|
|
this.focusSelfOrNextIfNotPossible();
|
|
});
|
|
}
|
|
|
|
async scheduleTaskTomorrow(): Promise<void> {
|
|
const tDate = this._dateService.getLogicalTodayDate();
|
|
tDate.setDate(tDate.getDate() + 1);
|
|
await this._scheduleForDay(tDate);
|
|
}
|
|
|
|
async scheduleTaskNextWeek(): Promise<void> {
|
|
const tDate = this._dateService.getLogicalTodayDate();
|
|
const dayOffset = getNextWeekDayOffset(this._dateAdapter, tDate);
|
|
tDate.setDate(tDate.getDate() + dayOffset);
|
|
await this._scheduleForDay(tDate);
|
|
}
|
|
|
|
async scheduleTaskNextMonth(): Promise<void> {
|
|
const tDate = this._dateService.getLogicalTodayDate();
|
|
tDate.setDate(1);
|
|
tDate.setMonth(tDate.getMonth() + 1);
|
|
await this._scheduleForDay(tDate);
|
|
}
|
|
|
|
private async _scheduleForDay(dayDate: Date): Promise<void> {
|
|
const day = getDbDateStr(dayDate);
|
|
const task = this.task();
|
|
if (task.dueWithTime) {
|
|
const newDate = combineDateAndTime(dayDate, new Date(task.dueWithTime));
|
|
const remindCfg = task.reminderId
|
|
? millisecondsDiffToRemindOption(task.dueWithTime, task.remindAt)
|
|
: (this._configService.cfg()?.reminder.defaultTaskRemindOption ??
|
|
DEFAULT_GLOBAL_CONFIG.reminder.defaultTaskRemindOption!);
|
|
|
|
this._taskService.scheduleTask(task, newDate.getTime(), remindCfg, false);
|
|
this._snackService.open({
|
|
type: 'SUCCESS',
|
|
msg: T.F.PLANNER.S.TASK_PLANNED_FOR,
|
|
ico: 'today',
|
|
translateParams: {
|
|
date: this._dateService.isToday(newDate)
|
|
? this._translateService.instant(T.G.TODAY_TAG_TITLE)
|
|
: (this._datePipe.transform(day, 'shortDate') as string),
|
|
extra: await this._plannerService.getSnackExtraStr(day),
|
|
},
|
|
});
|
|
} else {
|
|
this._store.dispatch(
|
|
PlannerActions.planTaskForDay({
|
|
task: task as TaskCopy,
|
|
day,
|
|
isShowSnack: true,
|
|
}),
|
|
);
|
|
}
|
|
this.focusSelfOrNextIfNotPossible();
|
|
}
|
|
|
|
async editTaskRepeatCfg(): Promise<void> {
|
|
const { DialogEditTaskRepeatCfgComponent } =
|
|
await import('../../task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component');
|
|
this._matDialog
|
|
.open(DialogEditTaskRepeatCfgComponent, {
|
|
data: {
|
|
task: this.task(),
|
|
targetDate: this.task().dueDay || getDbDateStr(new Date(this.task().created)),
|
|
},
|
|
})
|
|
.afterClosed()
|
|
.subscribe(() => this.focusSelf());
|
|
}
|
|
|
|
deleteTask(isClick: boolean = false): void {
|
|
// NOTE: prevents attempts to delete the same task multiple times
|
|
if (this._isTaskDeleteTriggered) {
|
|
return;
|
|
}
|
|
|
|
const isConfirmBeforeTaskDelete =
|
|
this._configService.cfg()?.tasks?.isConfirmBeforeDelete ?? true;
|
|
|
|
if (isConfirmBeforeTaskDelete) {
|
|
this._matDialog
|
|
.open(DialogConfirmComponent, {
|
|
data: {
|
|
okTxt: T.F.TASK.D_CONFIRM_DELETE.OK,
|
|
message: T.F.TASK.D_CONFIRM_DELETE.MSG,
|
|
translateParams: { title: this.task().title },
|
|
},
|
|
})
|
|
.afterClosed()
|
|
.pipe(takeUntilDestroyed(this._destroyRef))
|
|
.subscribe((isConfirm) => {
|
|
if (isConfirm) {
|
|
this._performDelete(isClick);
|
|
}
|
|
});
|
|
} else {
|
|
this._performDelete(isClick);
|
|
}
|
|
}
|
|
|
|
private _performDelete(isClick: boolean): void {
|
|
// NOTE: in case we want the focus behaviour on click we could use:
|
|
// this.focusSelf();
|
|
if (!isClick) {
|
|
this.focusNext(true);
|
|
}
|
|
|
|
this._isTaskDeleteTriggered = true;
|
|
this._taskService.remove(this.task());
|
|
}
|
|
|
|
startTask(): void {
|
|
this._taskService.setCurrentId(this.task().id);
|
|
this.focusSelf();
|
|
}
|
|
|
|
pauseTask(): void {
|
|
this._taskService.pauseCurrent();
|
|
}
|
|
|
|
togglePlayPause(): void {
|
|
if (this.isCurrent()) {
|
|
this.pauseTask();
|
|
} else {
|
|
this.startTask();
|
|
}
|
|
}
|
|
|
|
private _moveAndRefocus(
|
|
move: (id: string, parentId: string | undefined, isBacklog: boolean) => void,
|
|
): void {
|
|
const t = this.task();
|
|
// Top-level done tasks in the main list are ordered by completion date, so
|
|
// a manual reorder can't take effect — skip it to avoid emitting a spurious
|
|
// taskIds-reorder op that would sync to other devices. Done subtasks and
|
|
// backlog tasks are not auto-sorted, so they stay reorderable.
|
|
if (t.isDone && !t.parentId && !this.isBacklog()) {
|
|
return;
|
|
}
|
|
move(t.id, t.parentId, this.isBacklog());
|
|
// timeout required to let changes take place
|
|
setTimeout(() => this.focusSelf());
|
|
setTimeout(() => this.focusSelf(), 10);
|
|
}
|
|
|
|
moveTaskUp(): void {
|
|
this._moveAndRefocus((id, parentId, isBacklog) =>
|
|
this._taskService.moveUp(id, parentId, isBacklog),
|
|
);
|
|
}
|
|
|
|
moveTaskDown(): void {
|
|
this._moveAndRefocus((id, parentId, isBacklog) =>
|
|
this._taskService.moveDown(id, parentId, isBacklog),
|
|
);
|
|
}
|
|
|
|
moveTaskToTop(): void {
|
|
this._moveAndRefocus((id, parentId, isBacklog) =>
|
|
this._taskService.moveToTop(id, parentId, isBacklog),
|
|
);
|
|
}
|
|
|
|
moveTaskToBottom(): void {
|
|
this._moveAndRefocus((id, parentId, isBacklog) =>
|
|
this._taskService.moveToBottom(id, parentId, isBacklog),
|
|
);
|
|
}
|
|
|
|
handleArrowUp(): void {
|
|
if (this._focusGroupHeaderIfAtBoundary('prev')) {
|
|
return;
|
|
}
|
|
this.focusPrevious();
|
|
}
|
|
|
|
handleArrowDown(): void {
|
|
if (this._focusGroupHeaderIfAtBoundary('next')) {
|
|
return;
|
|
}
|
|
this.focusNext();
|
|
}
|
|
|
|
private _focusGroupHeaderIfAtBoundary(direction: 'prev' | 'next'): boolean {
|
|
const adjacent = findAdjacentFocusable(
|
|
this._elementRef.nativeElement,
|
|
direction,
|
|
GROUP_NAV_SELECTOR,
|
|
);
|
|
if (adjacent?.matches('.collapsible-header')) {
|
|
adjacent.focus();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
handleArrowLeft(): void {
|
|
const t = this.task();
|
|
const hasSubTasks = t.subTasks && t.subTasks.length > 0;
|
|
|
|
if (this.isSelected()) {
|
|
this.hideDetailPanel();
|
|
return;
|
|
}
|
|
if (hasSubTasks && t._hideSubTasksMode !== HideSubTasksMode.HideAll) {
|
|
this._taskService.toggleSubTaskMode(t.id, true, false);
|
|
return;
|
|
}
|
|
if (!t.parentId) {
|
|
const group = CollapsibleComponent.findClosestGroup(this._elementRef.nativeElement);
|
|
if (group?.isExpanded) {
|
|
group.focusHeader();
|
|
group.collapseIfExpanded();
|
|
return;
|
|
}
|
|
}
|
|
this.focusPrevious();
|
|
}
|
|
|
|
handleArrowRight(): void {
|
|
const t = this.task();
|
|
const hasSubTasks = t.subTasks && t.subTasks.length > 0;
|
|
|
|
if (hasSubTasks && t._hideSubTasksMode !== undefined) {
|
|
this._taskService.toggleSubTaskMode(t.id, false, false);
|
|
} else if (!this.isSelected()) {
|
|
this.showDetailPanel();
|
|
} else {
|
|
this.focusNext();
|
|
}
|
|
}
|
|
|
|
moveToBacklogWithFocus(): void {
|
|
const t = this.task();
|
|
if (t.projectId && !t.parentId) {
|
|
this.focusPrevious(true);
|
|
this.moveToBacklog();
|
|
}
|
|
}
|
|
|
|
moveToTodayWithFocus(): void {
|
|
const t = this.task();
|
|
if (t.projectId) {
|
|
this.focusNext(true, true);
|
|
this.moveToToday();
|
|
}
|
|
}
|
|
|
|
openProjectMenu(): void {
|
|
if (this.task().parentId) {
|
|
return;
|
|
}
|
|
this._loadProjectListIfNeeded();
|
|
if (!this.isProjectMenuLoaded()) {
|
|
this.isProjectMenuLoaded.set(true);
|
|
setTimeout(() => this.projectMenuTrigger()?.openMenu());
|
|
return;
|
|
}
|
|
this.projectMenuTrigger()?.openMenu();
|
|
}
|
|
|
|
_loadProjectListIfNeeded(): void {
|
|
// Only load if not already loaded
|
|
const currentProjectId = this.task().projectId || null;
|
|
const isLoadedForCurrentProject =
|
|
this._loadedProjectListForProjectId === currentProjectId &&
|
|
this._moveToProjectListSub &&
|
|
!this._moveToProjectListSub.closed;
|
|
|
|
if (isLoadedForCurrentProject) {
|
|
return;
|
|
}
|
|
|
|
this._moveToProjectListSub?.unsubscribe();
|
|
this._loadedProjectListForProjectId = currentProjectId;
|
|
|
|
this._moveToProjectListSub = this._projectService
|
|
.getProjectsWithoutIdInTreeOrder$(currentProjectId)
|
|
.subscribe((projects) => {
|
|
this.moveToProjectList.set(projects);
|
|
});
|
|
}
|
|
|
|
updateTaskTitleIfChanged({
|
|
newVal,
|
|
wasChanged,
|
|
blurEvent,
|
|
submitTrigger,
|
|
}: {
|
|
newVal: string;
|
|
wasChanged: boolean;
|
|
blurEvent?: FocusEvent;
|
|
submitTrigger: SubmitTrigger;
|
|
}): void {
|
|
const task = this.task();
|
|
|
|
if (wasChanged) {
|
|
this._taskService.update(task.id, { title: newVal });
|
|
}
|
|
|
|
if (submitTrigger === 'modEnter') {
|
|
this.addSubTask();
|
|
return;
|
|
}
|
|
|
|
// Only focus self if no input/textarea is receiving focus next
|
|
// This prevents stealing focus from any user input that was just clicked for editing
|
|
const nextFocusTarget = blurEvent?.relatedTarget as HTMLElement | null;
|
|
const isNextTargetInput =
|
|
nextFocusTarget &&
|
|
(nextFocusTarget.tagName.toLowerCase() === 'input' ||
|
|
nextFocusTarget.tagName.toLowerCase() === 'textarea' ||
|
|
nextFocusTarget.closest('task') !== null);
|
|
|
|
if (!isNextTargetInput) {
|
|
this.focusSelf();
|
|
}
|
|
}
|
|
|
|
openNotesFullscreen(): void {
|
|
const task = this.task();
|
|
// Saves-and-closes on a navigation (resize across the mobile breakpoint,
|
|
// Android back) instead of dropping the edit — see openFullscreenMarkdownDialog
|
|
// (#8434).
|
|
const dialogRef = openFullscreenMarkdownDialog(this._matDialog, this._location, {
|
|
content: task.notes || '',
|
|
taskId: task.id,
|
|
});
|
|
|
|
dialogRef.afterClosed().subscribe((result) => {
|
|
if (result?.action === 'DELETE') {
|
|
this._taskService.update(task.id, { notes: '' });
|
|
} else if (typeof result === 'string') {
|
|
this._taskService.update(task.id, { notes: result });
|
|
}
|
|
this.focusSelf();
|
|
});
|
|
}
|
|
|
|
estimateTime(): void {
|
|
if (this.task().subTaskIds?.length > 0) {
|
|
return;
|
|
}
|
|
|
|
this._matDialog
|
|
.open(DialogTimeEstimateComponent, {
|
|
data: { task: this.task() },
|
|
})
|
|
.afterClosed()
|
|
.subscribe(() => this.focusSelf());
|
|
}
|
|
|
|
addAttachment(): void {
|
|
this._matDialog
|
|
.open(DialogEditTaskAttachmentComponent, {
|
|
data: {},
|
|
})
|
|
.afterClosed()
|
|
.subscribe((result) => {
|
|
if (result) {
|
|
this._attachmentService.addAttachment(this.task().id, result);
|
|
}
|
|
this.focusSelf();
|
|
});
|
|
}
|
|
|
|
addSubTask(): void {
|
|
const task = this.task();
|
|
const parentId = task.parentId || task.id;
|
|
if (!task.parentId && task._hideSubTasksMode === HideSubTasksMode.HideAll) {
|
|
this._taskService.showSubTasks(task.id);
|
|
}
|
|
this._addSubtaskInputService.requestOpen(parentId);
|
|
}
|
|
|
|
onAddSubtaskInputClosed(reason: AddSubtaskInputCloseReason): void {
|
|
this.isAddSubtaskInputVisible.set(false);
|
|
const originTaskId = this._subtaskInputOriginTaskId;
|
|
this._subtaskInputOriginTaskId = null;
|
|
if (reason === 'escape') {
|
|
// Return focus to the task the draft was opened from (which may be a
|
|
// subtask) so keyboard navigation continues from there after cancelling.
|
|
this._refocusTaskAfterDraftCancel(originTaskId);
|
|
}
|
|
}
|
|
|
|
private _refocusTaskAfterDraftCancel(taskId: string | null): void {
|
|
if (isTouchActive()) {
|
|
return;
|
|
}
|
|
const targetId = taskId ?? this.task().id;
|
|
// Deferred so focus lands after the input's removal settles.
|
|
window.setTimeout(() => this._focusTaskById(targetId));
|
|
}
|
|
|
|
private _focusTaskById(taskId: string): void {
|
|
// A task can render in two places at once (main list + detail side panel);
|
|
// prefer the last instance — the side-panel one — mirroring the inline-edit
|
|
// focus resolution above.
|
|
const els = document.querySelectorAll<HTMLElement>('#t-' + CSS.escape(taskId));
|
|
els[els.length - 1]?.focus();
|
|
}
|
|
|
|
@throttle(200, { leading: true, trailing: false })
|
|
toggleDoneKeyboard(): void {
|
|
this.toggleTaskDone();
|
|
}
|
|
|
|
onSwipeRightTriggered(isTriggered: boolean): void {
|
|
if (this.task().isDone) {
|
|
this.showUndoneAnimation.set(isTriggered);
|
|
} else {
|
|
this.showDoneAnimation.set(isTriggered);
|
|
}
|
|
}
|
|
|
|
toggleTaskDone(): void {
|
|
window.clearTimeout(this._doneAnimationTimeout);
|
|
this.focusNext(true, true);
|
|
this._doneAnimationTimeout = this._taskService.toggleDoneWithAnimation(
|
|
this.task().id,
|
|
this.task().isDone,
|
|
(v) => this.showDoneAnimation.set(v),
|
|
);
|
|
}
|
|
|
|
showDetailPanel(): void {
|
|
this._taskService.setSelectedId(this.task().id);
|
|
this.focusSelf();
|
|
}
|
|
|
|
hideDetailPanel(): void {
|
|
// Explicitly deselect to close the panel; do NOT re-select (that's showDetailPanel's job).
|
|
this._taskService.setSelectedId(null);
|
|
this.focusSelf();
|
|
}
|
|
|
|
private _wasClickedInDoubleClickRange = false;
|
|
|
|
// Clicking the detail-panel toggle button while it shows the accent "issue
|
|
// updated" icon also dismisses that badge (toggleButtonIcon() === 'update'
|
|
// iff issueWasUpdated). This is the always-available way to clear it — unlike
|
|
// the issue-content "mark as checked" button, it does not depend on the issue
|
|
// data (re)loading, so the badge can never get stuck (e.g. removed remote issue).
|
|
onToggleDetailPanelBtnClick(ev?: MouseEvent): void {
|
|
const task = this.task();
|
|
if (task.issueWasUpdated) {
|
|
this._taskService.markIssueUpdatesAsRead(task.id);
|
|
}
|
|
this.toggleShowDetailPanel(ev);
|
|
}
|
|
|
|
toggleShowDetailPanel(ev?: MouseEvent): void {
|
|
const isInTaskDetailPanel =
|
|
this._elementRef.nativeElement.closest('task-detail-panel');
|
|
if (isInTaskDetailPanel && !this._wasClickedInDoubleClickRange) {
|
|
this._wasClickedInDoubleClickRange = true;
|
|
window.clearTimeout(this._doubleClickTimeout);
|
|
this._doubleClickTimeout = window.setTimeout(() => {
|
|
this._wasClickedInDoubleClickRange = false;
|
|
}, 400);
|
|
return;
|
|
}
|
|
|
|
if (this.isSelected()) {
|
|
this._taskService.setSelectedId(null);
|
|
} else {
|
|
this._taskService.setSelectedId(this.task().id);
|
|
}
|
|
if (ev) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
}
|
|
}
|
|
|
|
toggleShowAttachments(): void {
|
|
this._taskService.setSelectedId(this.task().id, TaskDetailTargetPanel.Attachments);
|
|
this.focusSelf();
|
|
}
|
|
|
|
openNotesPanel(): void {
|
|
this._taskService.setSelectedId(this.task().id, TaskDetailTargetPanel.Notes);
|
|
this.focusSelf();
|
|
}
|
|
|
|
toggleSubTaskMode(): void {
|
|
this._taskService.toggleSubTaskMode(this.task().id, true, true);
|
|
this.focusSelf();
|
|
}
|
|
|
|
isTagMenuVisible = signal(false);
|
|
|
|
async editTags(): Promise<void> {
|
|
this.isTagMenuVisible.set(true);
|
|
setTimeout(() => {
|
|
this.tagToggleMenuList()?.openMenu();
|
|
});
|
|
}
|
|
|
|
toggleTag(tagId: string): void {
|
|
const task = this.task();
|
|
const tagIds = task.tagIds.includes(tagId)
|
|
? task.tagIds.filter((id) => id !== tagId)
|
|
: [...task.tagIds, tagId];
|
|
|
|
this.onTagsUpdated(tagIds);
|
|
}
|
|
|
|
addToMyDay(): void {
|
|
const task = this.task();
|
|
this._store.dispatch(
|
|
TaskSharedActions.planTasksForToday({
|
|
taskIds: [task.id],
|
|
today: this._dateService.todayStr(),
|
|
startOfNextDayDiffMs: this._dateService.getStartOfNextDayDiffMs(),
|
|
parentTaskMap: { [task.id]: task.parentId },
|
|
}),
|
|
);
|
|
}
|
|
|
|
unschedule(): void {
|
|
this._store.dispatch(
|
|
TaskSharedActions.unscheduleTask({
|
|
id: this.task().id,
|
|
}),
|
|
);
|
|
}
|
|
|
|
titleBarClick(event: MouseEvent): void {
|
|
const targetEl = event.target as HTMLElement;
|
|
if (targetEl.closest('task-title')) {
|
|
return;
|
|
}
|
|
if (isTouchActive() && this.task().title.length) {
|
|
this.toggleShowDetailPanel(event);
|
|
} else {
|
|
this.focusSelf();
|
|
}
|
|
}
|
|
|
|
focusPrevious(isFocusReverseIfNotPossible: boolean = false): void {
|
|
if (isTouchActive()) {
|
|
return;
|
|
}
|
|
|
|
const taskEls = Array.from(document.querySelectorAll('task'));
|
|
const activeEl =
|
|
document.activeElement?.tagName.toLowerCase() === 'task'
|
|
? document.activeElement
|
|
: document.activeElement?.closest('task');
|
|
const currentIndex = taskEls.findIndex((el) => el === activeEl);
|
|
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,
|
|
isTaskMovedInList = false,
|
|
): void {
|
|
if (isTouchActive()) {
|
|
return;
|
|
}
|
|
|
|
const nextEl = this._getNextFocusEl(isTaskMovedInList);
|
|
this._nextFocusTaskEl = undefined;
|
|
|
|
if (nextEl) {
|
|
nextEl.focus();
|
|
// hacky but works
|
|
setTimeout(() => {
|
|
if (document.activeElement !== nextEl) {
|
|
nextEl.focus();
|
|
}
|
|
});
|
|
} else if (isFocusReverseIfNotPossible) {
|
|
this.focusPrevious();
|
|
}
|
|
}
|
|
|
|
focusSelf(): void {
|
|
if (isTouchActive()) {
|
|
return;
|
|
}
|
|
this._focusSelfElement();
|
|
}
|
|
|
|
focusSelfOrNextIfNotPossible(): void {
|
|
if (isTouchActive()) {
|
|
return;
|
|
}
|
|
|
|
this.focusSelf();
|
|
// we don't clear the timeout since this should be executed if task is gone
|
|
window.setTimeout(() => {
|
|
if (
|
|
!document.activeElement ||
|
|
document.activeElement.tagName.toLowerCase() !== 'task'
|
|
) {
|
|
this.focusNext(true);
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
private _focusSelfElement(): void {
|
|
this._elementRef.nativeElement.focus();
|
|
}
|
|
|
|
focusTitleForEdit(): void {
|
|
const taskTitleEditEl = this.taskTitleEditEl();
|
|
if (!taskTitleEditEl) {
|
|
TaskLog.log(taskTitleEditEl);
|
|
throw new Error('No el');
|
|
}
|
|
taskTitleEditEl.focusInput();
|
|
}
|
|
|
|
onHostTouchStart(): void {
|
|
this._dragReadyTimeout = window.setTimeout(() => {
|
|
this.isDragReady.set(true);
|
|
}, DRAG_DELAY_FOR_TOUCH);
|
|
}
|
|
|
|
onHostTouchEnd(): void {
|
|
this._cancelDragReady();
|
|
}
|
|
|
|
// A confirmed horizontal swipe (open menu / mark done) is not a drag, so
|
|
// cancel the pending long-press drag-ready state before it can fire mid-swipe.
|
|
onSwipeStart(): void {
|
|
this._cancelDragReady();
|
|
}
|
|
|
|
private _cancelDragReady(): void {
|
|
window.clearTimeout(this._dragReadyTimeout);
|
|
this.isDragReady.set(false);
|
|
}
|
|
|
|
onHostContextMenu(event: MouseEvent): void {
|
|
if (isTouchActive()) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
this.openContextMenu(event);
|
|
}
|
|
|
|
openContextMenu(event?: TouchEvent | MouseEvent | KeyboardEvent): void {
|
|
this.taskTitleEditEl()?.cancelEditing();
|
|
if (event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if ('stopImmediatePropagation' in event) {
|
|
event.stopImmediatePropagation();
|
|
}
|
|
}
|
|
|
|
if (!this.isContextMenuLoaded()) {
|
|
this.isContextMenuLoaded.set(true);
|
|
setTimeout(() => {
|
|
this.taskContextMenu()?.open(event);
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.taskContextMenu()?.open(event);
|
|
}
|
|
|
|
onTagsUpdated(tagIds: string[]): void {
|
|
this._taskService.updateTags(this.task(), tagIds);
|
|
}
|
|
|
|
// TODO extract so service
|
|
moveTaskToProject(projectId: string): void {
|
|
const t = this.task();
|
|
if (projectId === t.projectId) {
|
|
return;
|
|
} else if (!t.repeatCfgId) {
|
|
this._taskService.moveToProject(t, projectId);
|
|
setTimeout(() => this.focusNext(true));
|
|
} else {
|
|
forkJoin([
|
|
this._taskRepeatCfgService
|
|
.getTaskRepeatCfgByIdAllowUndefined$(t.repeatCfgId)
|
|
.pipe(first()),
|
|
this._taskService.getTasksWithSubTasksByRepeatCfgId$(t.repeatCfgId).pipe(first()),
|
|
this._taskService.getArchiveTasksForRepeatCfgId(t.repeatCfgId),
|
|
this._projectService.getByIdOnce$(projectId),
|
|
])
|
|
.pipe(
|
|
concatMap(
|
|
([
|
|
reminderCfg,
|
|
nonArchiveInstancesWithSubTasks,
|
|
archiveInstances,
|
|
targetProject,
|
|
]) => {
|
|
TaskLog.log({
|
|
reminderCfg,
|
|
nonArchiveInstancesWithSubTasks,
|
|
archiveInstances,
|
|
});
|
|
|
|
// Repeat config was deleted (e.g. via cross-client sync) but the task
|
|
// still references it — treat it as a plain task move instead of
|
|
// crashing on the missing config. (#8715)
|
|
if (!reminderCfg) {
|
|
this._taskService.moveToProject(this.task(), projectId);
|
|
setTimeout(() => this.focusNext(true));
|
|
return EMPTY;
|
|
}
|
|
|
|
// if there is only a single instance (probably just created) than directly update the task repeat cfg
|
|
if (
|
|
nonArchiveInstancesWithSubTasks.length === 1 &&
|
|
archiveInstances.length === 0
|
|
) {
|
|
this._taskRepeatCfgService.updateTaskRepeatCfg(reminderCfg.id, {
|
|
projectId,
|
|
});
|
|
this._taskService.moveToProject(this.task(), projectId);
|
|
setTimeout(() => this.focusNext(true));
|
|
return EMPTY;
|
|
}
|
|
|
|
return this._matDialog
|
|
.open(DialogConfirmComponent, {
|
|
data: {
|
|
okTxt: T.F.TASK_REPEAT.D_CONFIRM_MOVE_TO_PROJECT.OK,
|
|
message: T.F.TASK_REPEAT.D_CONFIRM_MOVE_TO_PROJECT.MSG,
|
|
translateParams: {
|
|
projectName: targetProject?.title ?? _MISSING_PROJECT_,
|
|
tasksNr:
|
|
nonArchiveInstancesWithSubTasks.length + archiveInstances.length,
|
|
},
|
|
},
|
|
})
|
|
.afterClosed()
|
|
.pipe(
|
|
tap((isConfirm) => {
|
|
if (isConfirm) {
|
|
this._taskRepeatCfgService.updateTaskRepeatCfg(reminderCfg.id, {
|
|
projectId,
|
|
});
|
|
nonArchiveInstancesWithSubTasks.forEach((nonArchiveTask) => {
|
|
this._taskService.moveToProject(nonArchiveTask, projectId);
|
|
});
|
|
|
|
const archiveUpdates: Update<TaskCopy>[] = [];
|
|
archiveInstances.forEach((archiveTask) => {
|
|
archiveUpdates.push({
|
|
id: archiveTask.id,
|
|
changes: { projectId },
|
|
});
|
|
if (archiveTask.subTaskIds.length) {
|
|
archiveTask.subTaskIds.forEach((subId) => {
|
|
archiveUpdates.push({
|
|
id: subId,
|
|
changes: { projectId },
|
|
});
|
|
});
|
|
}
|
|
});
|
|
this._taskService.updateArchiveTasks(archiveUpdates);
|
|
setTimeout(() => this.focusNext(true));
|
|
}
|
|
}),
|
|
);
|
|
},
|
|
),
|
|
)
|
|
.subscribe(() => this.focusSelf());
|
|
}
|
|
}
|
|
|
|
moveToBacklog(): void {
|
|
const t = this.task();
|
|
if (t.projectId && !t.parentId) {
|
|
// Moving to the backlog is a list-position change only; it must not
|
|
// alter the task's schedule (#8592).
|
|
this._projectService.moveTaskToBacklog(t.id, t.projectId);
|
|
}
|
|
}
|
|
|
|
moveToToday(): void {
|
|
const t = this.task();
|
|
if (t.projectId) {
|
|
// Moving to the regular list is a list-position change only; it must not
|
|
// schedule the task for today (#8592).
|
|
this._projectService.moveTaskToTodayList(t.id, t.projectId);
|
|
}
|
|
}
|
|
|
|
trackByProjectId(i: number, project: Project): string {
|
|
return project.id;
|
|
}
|
|
|
|
private _storeNextFocusEl(): void {
|
|
this._nextFocusTaskEl = this._getNextFocusEl();
|
|
}
|
|
|
|
private _getNextFocusEl(isTaskMovedInList = false): HTMLElement | undefined {
|
|
if (this._nextFocusTaskEl) {
|
|
return this._nextFocusTaskEl;
|
|
}
|
|
|
|
const taskEls = Array.from(document.querySelectorAll('task'));
|
|
const activeEl =
|
|
document.activeElement?.tagName.toLowerCase() === 'task'
|
|
? document.activeElement
|
|
: document.activeElement?.closest('task');
|
|
const currentIndex = taskEls.findIndex((el) => el === activeEl);
|
|
const nextEl = isTaskMovedInList
|
|
? (() => {
|
|
// if a parent task is moved in list, as it is for when toggling done,
|
|
// we don't want to focus the next sub-task, but the next main task instead
|
|
if (this.task().subTaskIds.length) {
|
|
return taskEls.find((el, i) => {
|
|
return i > currentIndex && el.parentElement?.closest('task');
|
|
}) as HTMLElement | undefined;
|
|
}
|
|
return taskEls[currentIndex + 1] as HTMLElement;
|
|
})()
|
|
: (taskEls[currentIndex + 1] as HTMLElement);
|
|
return nextEl;
|
|
}
|
|
|
|
get kb(): KeyboardConfig {
|
|
if (isTouchActive()) {
|
|
return EMPTY_KEYBOARD_CONFIG;
|
|
}
|
|
return keyboardConfigOrEmpty(this._configService.cfg()?.keyboard as KeyboardConfig);
|
|
}
|
|
|
|
protected readonly ICAL_TYPE = ICAL_TYPE;
|
|
}
|