mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
Fixes the Today background leaking onto non-context pages (Planner, Schedule, Boards, Config) and the startup flash reported in #8643. The active work context stays "Today" (the reducer default) on pages that aren't a tag/project, so its background was shown there wrongly. Resolve the background per-route instead: per-context image -> global wallpaper -> none, with overlay-opacity and blur following whichever image is actually shown (never the sticky context's on global pages). - add a global wallpaper (image dark/light + overlay opacity + blur) to MiscConfig, surfaced via a "Set wallpaper..." dialog under Theme - resolveBackground() replaces the URL image helper and returns the styling source; a cleared/empty image falls back to the global one - app.component derives opacity/blur from the resolved source Builds on the URL-aware background stream contributed in the PR. Co-authored-by: Johannes Millan <johannes.millan@gmail.com> Co-authored-by: jibin7jose <jibin7jose@users.noreply.github.com>
556 lines
21 KiB
TypeScript
556 lines
21 KiB
TypeScript
import {
|
||
AfterViewInit,
|
||
ChangeDetectionStrategy,
|
||
Component,
|
||
computed,
|
||
DestroyRef,
|
||
effect,
|
||
ElementRef,
|
||
HostBinding,
|
||
HostListener,
|
||
inject,
|
||
NgZone,
|
||
OnDestroy,
|
||
signal,
|
||
ViewChild,
|
||
} from '@angular/core';
|
||
import { toSignal } from '@angular/core/rxjs-interop';
|
||
import { ShortcutService } from './core-ui/shortcut/shortcut.service';
|
||
import { GlobalConfigService } from './features/config/global-config.service';
|
||
import { TaskWidgetSettingsService } from './features/config/task-widget-settings.service';
|
||
import { LayoutService } from './core-ui/layout/layout.service';
|
||
import { SnackService } from './core/snack/snack.service';
|
||
import { IS_ELECTRON } from './app.constants';
|
||
import { IS_MAC } from './util/is-mac';
|
||
import { expandAnimation } from './ui/animations/expand.ani';
|
||
import { warpRouteAnimation } from './ui/animations/warp-route';
|
||
import { firstValueFrom, Subscription } from 'rxjs';
|
||
import { fadeAnimation } from './ui/animations/fade.ani';
|
||
import { BannerService } from './core/banner/banner.service';
|
||
import { LS } from './core/persistence/storage-keys.const';
|
||
import { BannerId } from './core/banner/banner.model';
|
||
import { T } from './t.const';
|
||
import { GlobalThemeService } from './core/theme/global-theme.service';
|
||
import { resolveBgImageToDataUrl } from './core/theme/resolve-bg-image-to-data-url.util';
|
||
import { LanguageService } from './core/language/language.service';
|
||
import { WorkContextService } from './features/work-context/work-context.service';
|
||
import { SyncTriggerService } from './imex/sync/sync-trigger.service';
|
||
import { ActivatedRoute, RouterOutlet } from '@angular/router';
|
||
import { concatMap, first, take } from 'rxjs/operators';
|
||
|
||
import { IS_MOBILE } from './util/is-mobile';
|
||
import { recordSearchNavDebug } from './util/search-nav-debug';
|
||
import { warpAnimation, warpInAnimation } from './ui/animations/warp.ani';
|
||
import { AddTaskBarComponent } from './features/tasks/add-task-bar/add-task-bar.component';
|
||
import { Dir } from '@angular/cdk/bidi';
|
||
import { MagicSideNavComponent } from './core-ui/magic-side-nav/magic-side-nav.component';
|
||
import { MainHeaderComponent } from './core-ui/main-header/main-header.component';
|
||
import { BannerComponent } from './core/banner/banner/banner.component';
|
||
import { GlobalProgressBarComponent } from './core-ui/global-progress-bar/global-progress-bar.component';
|
||
import { FocusModeOverlayComponent } from './features/focus-mode/focus-mode-overlay/focus-mode-overlay.component';
|
||
import { DOCUMENT } from '@angular/common';
|
||
import { RightPanelComponent } from './features/right-panel/right-panel.component';
|
||
import { selectIsOverlayShown } from './features/focus-mode/store/focus-mode.selectors';
|
||
import { Store } from '@ngrx/store';
|
||
import { TranslatePipe } from '@ngx-translate/core';
|
||
import { MatDialog } from '@angular/material/dialog';
|
||
import { MarkdownPasteService } from './features/tasks/markdown-paste.service';
|
||
import { TaskService } from './features/tasks/task.service';
|
||
import { MatMenuItem } from '@angular/material/menu';
|
||
import { MatIcon } from '@angular/material/icon';
|
||
import { NoteStartupBannerService } from './features/note/note-startup-banner.service';
|
||
import { SyncSafetyBannerService } from './imex/sync/sync-safety-banner.service';
|
||
import { SuperSyncEncryptionMigrationBannerService } from './imex/sync/super-sync-encryption-migration-banner.service';
|
||
import { ProjectService } from './features/project/project.service';
|
||
import { TagService } from './features/tag/tag.service';
|
||
import { ContextMenuComponent } from './ui/context-menu/context-menu.component';
|
||
import { WorkContextType } from './features/work-context/work-context.model';
|
||
import { SectionService } from './features/section/section.service';
|
||
import { DialogPromptComponent } from './ui/dialog-prompt/dialog-prompt.component';
|
||
import { TODAY_TAG } from './features/tag/tag.const';
|
||
import { openWorkContextSettingsDialog } from './features/work-context/dialog-work-context-settings/open-work-context-settings-dialog';
|
||
import { isInputElement } from './util/dom-element';
|
||
import { MobileBottomNavComponent } from './core-ui/mobile-bottom-nav/mobile-bottom-nav.component';
|
||
import { StartupService } from './core/startup/startup.service';
|
||
import { DataInitStateService } from './core/data-init/data-init-state.service';
|
||
import { ExampleTasksService } from './core/example-tasks/example-tasks.service';
|
||
import { KeyboardLayoutService } from './core/keyboard-layout/keyboard-layout.service';
|
||
import { setKeyboardLayoutService } from './util/check-key-combo';
|
||
import { OnboardingPresetSelectionComponent } from './features/onboarding/onboarding-preset-selection.component';
|
||
import { OnboardingHintComponent } from './features/onboarding/onboarding-hint.component';
|
||
import { OnboardingHintService } from './features/onboarding/onboarding-hint.service';
|
||
import { MaterialIconsLoaderService } from './ui/material-icons-loader.service';
|
||
import { BrowserTitleService } from './core/browser-title/browser-title.service';
|
||
|
||
const ONBOARDING_PRESET_EXIT_DELAY = 1000;
|
||
const ONBOARDING_ENTRANCE_COMPLETE_DELAY = 2000;
|
||
const ENTRANCE_ANIMATION_DURATION = 1500;
|
||
|
||
interface BeforeInstallPromptEvent extends Event {
|
||
prompt(): Promise<void>;
|
||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||
}
|
||
|
||
@Component({
|
||
selector: 'app-root',
|
||
templateUrl: './app.component.html',
|
||
styleUrls: ['./app.component.scss'],
|
||
animations: [
|
||
expandAnimation,
|
||
warpRouteAnimation,
|
||
fadeAnimation,
|
||
warpAnimation,
|
||
warpInAnimation,
|
||
],
|
||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
imports: [
|
||
AddTaskBarComponent,
|
||
Dir,
|
||
MagicSideNavComponent,
|
||
MainHeaderComponent,
|
||
BannerComponent,
|
||
RightPanelComponent,
|
||
RouterOutlet,
|
||
GlobalProgressBarComponent,
|
||
FocusModeOverlayComponent,
|
||
MatMenuItem,
|
||
MatIcon,
|
||
TranslatePipe,
|
||
ContextMenuComponent,
|
||
MobileBottomNavComponent,
|
||
OnboardingPresetSelectionComponent,
|
||
OnboardingHintComponent,
|
||
],
|
||
})
|
||
export class AppComponent implements OnDestroy, AfterViewInit {
|
||
private _globalConfigService = inject(GlobalConfigService);
|
||
private _shortcutService = inject(ShortcutService);
|
||
private _bannerService = inject(BannerService);
|
||
private _snackService = inject(SnackService);
|
||
private _globalThemeService = inject(GlobalThemeService);
|
||
private _languageService = inject(LanguageService);
|
||
private _activatedRoute = inject(ActivatedRoute);
|
||
private _matDialog = inject(MatDialog);
|
||
private _markdownPasteService = inject(MarkdownPasteService);
|
||
private _taskService = inject(TaskService);
|
||
private _projectService = inject(ProjectService);
|
||
private _tagService = inject(TagService);
|
||
private _destroyRef = inject(DestroyRef);
|
||
private _noteStartupBannerService = inject(NoteStartupBannerService);
|
||
private _syncSafetyBannerService = inject(SyncSafetyBannerService);
|
||
private _superSyncEncryptionMigrationBannerService = inject(
|
||
SuperSyncEncryptionMigrationBannerService,
|
||
);
|
||
private _ngZone = inject(NgZone);
|
||
private _document = inject(DOCUMENT, { optional: true });
|
||
private _startupService = inject(StartupService);
|
||
// Injected for side-effect: creates example tasks on first run
|
||
private _exampleTasksService = inject(ExampleTasksService);
|
||
// Injected for side-effect: loads per-instance task widget settings from
|
||
// localStorage and pushes them to the Electron main process at app boot,
|
||
// before the user opens the (lazy-loaded) Settings page.
|
||
private _taskWidgetSettingsService = inject(TaskWidgetSettingsService);
|
||
private _keyboardLayoutService = inject(KeyboardLayoutService);
|
||
private _dataInitStateService = inject(DataInitStateService);
|
||
private _materialIconsLoaderService = inject(MaterialIconsLoaderService);
|
||
readonly onboardingHintService = inject(OnboardingHintService);
|
||
|
||
private _syncTriggerService = inject(SyncTriggerService);
|
||
readonly workContextService = inject(WorkContextService);
|
||
readonly layoutService = inject(LayoutService);
|
||
readonly globalThemeService = inject(GlobalThemeService);
|
||
readonly _store = inject(Store);
|
||
private _sectionService = inject(SectionService);
|
||
private _browserTitleService = inject(BrowserTitleService);
|
||
private _hasShownLegacyFileBgSnack = false;
|
||
readonly T = T;
|
||
readonly TODAY_TAG_ID = TODAY_TAG.id;
|
||
readonly isShowMobileButtonNav = this.layoutService.isShowMobileBottomNav;
|
||
|
||
@ViewChild('routeWrapper', { read: ElementRef }) routeWrapper?: ElementRef<HTMLElement>;
|
||
@ViewChild(RouterOutlet) private _routerOutlet?: RouterOutlet;
|
||
|
||
@HostBinding('class.isWorkViewScrolled') get isWorkViewScrolledClass(): boolean {
|
||
return this.layoutService.isWorkViewScrolled();
|
||
}
|
||
|
||
@HostBinding('@.disabled') get isDisableAnimations(): boolean {
|
||
return this._isDisableAnimations();
|
||
}
|
||
|
||
private _isDisableAnimations = computed(() => {
|
||
const misc = this._globalConfigService.misc();
|
||
return misc?.isDisableAnimations ?? false;
|
||
});
|
||
|
||
// Experimental: vertical action strip on the right edge instead of the
|
||
// horizontal top header. Switches the DOM layout live (see template).
|
||
readonly isVerticalActionBar = computed(
|
||
() => this._globalConfigService.misc()?.isVerticalActionBar ?? false,
|
||
);
|
||
|
||
isRTL: boolean = false;
|
||
|
||
private _isOverlayShownFromStore = toSignal(this._store.select(selectIsOverlayShown), {
|
||
initialValue: false,
|
||
});
|
||
|
||
// Only show focus overlay if both the store says to show it AND the feature is enabled
|
||
isShowFocusOverlay = computed(
|
||
() =>
|
||
this._isOverlayShownFromStore() &&
|
||
this._globalConfigService.appFeatures().isFocusModeEnabled,
|
||
);
|
||
|
||
private readonly _activeWorkContextId = toSignal(
|
||
this.workContextService.activeWorkContextId$,
|
||
{ initialValue: null },
|
||
);
|
||
|
||
readonly resolvedBgImage = signal<string | null>(null);
|
||
|
||
isShowOnboardingPresets = signal(
|
||
!localStorage.getItem(LS.ONBOARDING_PRESET_DONE) &&
|
||
!localStorage.getItem(LS.IS_SKIP_TOUR),
|
||
);
|
||
|
||
private _subs: Subscription = new Subscription();
|
||
|
||
constructor() {
|
||
this._startupService.init();
|
||
void this._materialIconsLoaderService.ensureFontReady();
|
||
|
||
// Skip onboarding for existing users with data
|
||
if (this.isShowOnboardingPresets()) {
|
||
this._dataInitStateService.isAllDataLoadedInitially$
|
||
.pipe(
|
||
concatMap(() => this._projectService.list$),
|
||
first(),
|
||
)
|
||
.subscribe((projectList) => {
|
||
if (projectList.length > 2) {
|
||
localStorage.setItem(LS.ONBOARDING_PRESET_DONE, 'true');
|
||
this.isShowOnboardingPresets.set(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Clear app entrance animation after it completes
|
||
if (this.isAppEntrance()) {
|
||
setTimeout(() => {
|
||
this.isAppEntrance.set(false);
|
||
}, ENTRANCE_ANIMATION_DURATION);
|
||
}
|
||
|
||
// Use effect to react to language RTL changes
|
||
effect(() => {
|
||
const val = this._languageService.isLangRTL();
|
||
this.isRTL = val;
|
||
document.dir = this.isRTL ? 'rtl' : 'ltr';
|
||
});
|
||
|
||
this._subs.add(
|
||
this._activatedRoute.queryParams.subscribe((params) => {
|
||
if (!!params.focusItem) {
|
||
recordSearchNavDebug('appComponent:focusQueryParam', {
|
||
focusItem: params.focusItem,
|
||
url: window.location.pathname + window.location.search,
|
||
});
|
||
this._focusElement(params.focusItem);
|
||
}
|
||
}),
|
||
);
|
||
|
||
// init theme and body class handlers
|
||
this._globalThemeService.init();
|
||
|
||
let bgResolveRequestId = 0;
|
||
effect(() => {
|
||
const bgImage = this._globalThemeService.backgroundImg();
|
||
const currentRequestId = ++bgResolveRequestId;
|
||
if (
|
||
typeof bgImage === 'string' &&
|
||
bgImage.startsWith('file://') &&
|
||
!this._hasShownLegacyFileBgSnack
|
||
) {
|
||
this._hasShownLegacyFileBgSnack = true;
|
||
this._snackService.open({
|
||
msg: T.F.PROJECT.FORM_THEME.S_BACKGROUND_IMAGE_RESELECT_REQUIRED,
|
||
type: 'WARNING',
|
||
config: { duration: 0 },
|
||
});
|
||
}
|
||
void resolveBgImageToDataUrl(bgImage).then((resolved) => {
|
||
// Ignore stale resolutions when the source changed mid-read.
|
||
if (currentRequestId === bgResolveRequestId) {
|
||
this.resolvedBgImage.set(resolved);
|
||
}
|
||
});
|
||
});
|
||
|
||
this._syncTriggerService.afterInitialSyncDoneAndDataLoadedInitially$
|
||
.pipe(take(1))
|
||
.subscribe(() => {
|
||
void this._noteStartupBannerService.showLastNoteIfNeeded();
|
||
this._syncSafetyBannerService.showReminderIfNeeded();
|
||
void this._superSyncEncryptionMigrationBannerService.showBannerIfNeeded();
|
||
});
|
||
|
||
// ! For keyboard shortcuts to work correctly with any layouts (QWERTZ/AZERTY/etc) - user's keyboard layout must be presaved
|
||
// Connect the service to the utility functions
|
||
setKeyboardLayoutService(this._keyboardLayoutService);
|
||
// Defer keyboard layout detection to idle time for better initial load performance,
|
||
// EXCEPT on macOS Electron where it is needed eagerly for the initial global shortcut registration.
|
||
if (IS_ELECTRON && IS_MAC) {
|
||
void this._keyboardLayoutService.saveUserLayout();
|
||
} else if (typeof requestIdleCallback === 'function') {
|
||
requestIdleCallback(() => this._keyboardLayoutService.saveUserLayout());
|
||
} else {
|
||
setTimeout(() => this._keyboardLayoutService.saveUserLayout(), 0);
|
||
}
|
||
}
|
||
|
||
@HostListener('document:paste', ['$event']) onPaste(ev: ClipboardEvent): void {
|
||
// Skip handling inside input elements
|
||
const target = ev.target as HTMLElement;
|
||
if (isInputElement(target)) return;
|
||
|
||
const clipboardData = ev.clipboardData;
|
||
if (!clipboardData) return;
|
||
|
||
const pastedText = clipboardData.getData('text/plain');
|
||
if (!pastedText) return;
|
||
|
||
if (!this._markdownPasteService.isMarkdownTaskList(pastedText)) return;
|
||
|
||
// Prevent default paste behavior
|
||
ev.preventDefault();
|
||
|
||
// Check if paste is happening on a task element
|
||
let taskId: string | null = null;
|
||
let taskTitle: string | null = null;
|
||
let isSubTask = false;
|
||
|
||
// Find the nearest task element via the data-task-id attribute (set
|
||
// on the <task> host). Avoids brittle id-prefix scans that could match
|
||
// unrelated elements whose id happens to start with "t-".
|
||
const taskEl = target.closest<HTMLElement>('[data-task-id]');
|
||
|
||
if (taskEl) {
|
||
taskId = taskEl.getAttribute('data-task-id');
|
||
}
|
||
|
||
if (taskId) {
|
||
// Get task data to determine if it's a sub-task
|
||
this._taskService.getByIdOnce$(taskId).subscribe((task) => {
|
||
if (task) {
|
||
taskTitle = task.title;
|
||
isSubTask = !!task.parentId;
|
||
this._markdownPasteService.handleMarkdownPaste(
|
||
pastedText,
|
||
taskId,
|
||
taskTitle,
|
||
isSubTask,
|
||
);
|
||
} else {
|
||
// Fallback: handle as parent tasks if task not found
|
||
this._markdownPasteService.handleMarkdownPaste(pastedText, null);
|
||
}
|
||
});
|
||
} else {
|
||
// Handle as parent tasks since no specific task context
|
||
this._markdownPasteService.handleMarkdownPaste(pastedText, null);
|
||
}
|
||
}
|
||
|
||
@HostListener('window:beforeinstallprompt', ['$event']) onBeforeInstallPrompt(
|
||
e: BeforeInstallPromptEvent,
|
||
): void {
|
||
if (
|
||
IS_ELECTRON ||
|
||
localStorage.getItem(LS.WEB_APP_INSTALL) ||
|
||
OnboardingHintService.isOnboardingInProgress()
|
||
) {
|
||
return;
|
||
}
|
||
|
||
// Prevent Chrome 67 and earlier from automatically showing the prompt
|
||
e.preventDefault();
|
||
|
||
window.setTimeout(
|
||
() => {
|
||
this._bannerService.open({
|
||
id: BannerId.InstallWebApp,
|
||
msg: T.APP.B_INSTALL.MSG,
|
||
action: {
|
||
label: T.APP.B_INSTALL.INSTALL,
|
||
fn: () => {
|
||
e.prompt();
|
||
},
|
||
},
|
||
action2: {
|
||
label: T.APP.B_INSTALL.IGNORE,
|
||
fn: () => {
|
||
localStorage.setItem(LS.WEB_APP_INSTALL, 'true');
|
||
},
|
||
},
|
||
});
|
||
},
|
||
2 * 60 * 1000,
|
||
);
|
||
}
|
||
|
||
getPage(outlet: RouterOutlet): string {
|
||
return outlet.activatedRouteData.page || 'one';
|
||
}
|
||
|
||
/**
|
||
* The background context menu is scoped to the task-list / work-view routes
|
||
* (`tag/:id/tasks`, `project/:id/tasks`) so it never opens on unrelated pages
|
||
* like Habits or Config. See #7734.
|
||
*/
|
||
isWorkViewPage(): boolean {
|
||
if (!this._routerOutlet) {
|
||
return false;
|
||
}
|
||
const page = this.getPage(this._routerOutlet);
|
||
return page === 'tag-tasks' || page === 'project-tasks';
|
||
}
|
||
|
||
getActiveWorkContextId(): string | null {
|
||
return this._activeWorkContextId() ?? null;
|
||
}
|
||
|
||
onTaskAdded({ taskId }: { taskId: string; isAddToBottom: boolean }): void {
|
||
this.layoutService.setPendingFocusTaskId(taskId);
|
||
this.layoutService.scrollToNewTask(taskId);
|
||
}
|
||
|
||
// Opacity + blur follow the resolved background source (per-context image or
|
||
// the global wallpaper), resolved centrally by GlobalThemeService — on
|
||
// non-context pages the active context is the sticky "Today" default and must
|
||
// not style the wallpaper.
|
||
readonly bgOverlayOpacity = this._globalThemeService.bgOverlayOpacity;
|
||
readonly bgImageBlur = this._globalThemeService.bgImageBlur;
|
||
|
||
readonly bgImageBlurFilter = computed((): string => {
|
||
const blur = this.bgImageBlur();
|
||
|
||
return blur > 0 ? `blur(${blur}px)` : 'none';
|
||
});
|
||
|
||
async openSettings(): Promise<void> {
|
||
const isForProject =
|
||
this.workContextService.activeWorkContextType === WorkContextType.PROJECT;
|
||
const contextId = this.workContextService.activeWorkContextId;
|
||
if (!contextId) {
|
||
return;
|
||
}
|
||
const entity = isForProject
|
||
? await firstValueFrom(this._projectService.getByIdOnce$(contextId))
|
||
: await firstValueFrom(this._tagService.getTagById$(contextId).pipe(first()));
|
||
if (!entity) {
|
||
return;
|
||
}
|
||
|
||
await openWorkContextSettingsDialog(this._matDialog, {
|
||
isProject: isForProject,
|
||
entity,
|
||
});
|
||
}
|
||
|
||
async addSection(): Promise<void> {
|
||
const ctxId = this.workContextService.activeWorkContextId;
|
||
const ctxType = this.workContextService.activeWorkContextType;
|
||
if (!ctxId || !ctxType) return;
|
||
const title = await firstValueFrom(
|
||
this._matDialog
|
||
.open(DialogPromptComponent, { data: { placeholder: T.WW.ADD_SECTION_TITLE } })
|
||
.afterClosed(),
|
||
);
|
||
if (typeof title === 'string' && title.trim()) {
|
||
this._sectionService.addSection(title, ctxId, ctxType);
|
||
}
|
||
}
|
||
|
||
isAppEntrance = signal(!this.isShowOnboardingPresets());
|
||
|
||
onPresetSelected(): void {
|
||
this.isAppEntrance.set(true);
|
||
setTimeout(() => {
|
||
this.isShowOnboardingPresets.set(false);
|
||
}, ONBOARDING_PRESET_EXIT_DELAY);
|
||
setTimeout(() => {
|
||
this.isAppEntrance.set(false);
|
||
this.onboardingHintService.startAfterPresetSelection();
|
||
}, ONBOARDING_ENTRANCE_COMPLETE_DELAY);
|
||
}
|
||
|
||
// Returning user set up sync from onboarding: just reveal the app with their
|
||
// synced data — no preset applied, no new-user hint tour.
|
||
onOnboardingDismissed(): void {
|
||
this.isShowOnboardingPresets.set(false);
|
||
}
|
||
|
||
ngAfterViewInit(): void {
|
||
this._ngZone.runOutsideAngular(() => {
|
||
const doc = this._document!;
|
||
// Handle global document events outside Angular to avoid change detection churn.
|
||
// - dragover/drop: block the browser's default file-drop navigation.
|
||
// - keydown: route shortcuts and only re-enter Angular when they matter.
|
||
// Prevent the browser from treating file drops as navigation events
|
||
const onDragOver = (ev: DragEvent): void => {
|
||
ev.preventDefault();
|
||
};
|
||
|
||
// Ensure accidental file drops don’t replace the SPA with the dropped file
|
||
const onDrop = (ev: DragEvent): void => {
|
||
ev.preventDefault();
|
||
};
|
||
|
||
const onKeyDown = (ev: KeyboardEvent): void => {
|
||
this._ngZone.run(() => {
|
||
void this._shortcutService.handleKeyDown(ev);
|
||
});
|
||
};
|
||
|
||
doc.addEventListener('dragover', onDragOver, { passive: false });
|
||
doc.addEventListener('drop', onDrop, { passive: false });
|
||
doc.addEventListener('keydown', onKeyDown);
|
||
|
||
this._destroyRef.onDestroy(() => {
|
||
doc.removeEventListener('dragover', onDragOver);
|
||
doc.removeEventListener('drop', onDrop);
|
||
doc.removeEventListener('keydown', onKeyDown);
|
||
});
|
||
});
|
||
}
|
||
|
||
ngOnDestroy(): void {
|
||
this._subs.unsubscribe();
|
||
}
|
||
|
||
/**
|
||
* since page load and animation time are not always equal
|
||
* retrying until the rendered task row is available avoids missing focus targets
|
||
*/
|
||
private _focusElement(id: string): void {
|
||
recordSearchNavDebug('appComponent:focusElement', {
|
||
taskId: id,
|
||
url: window.location.pathname + window.location.search,
|
||
});
|
||
this.layoutService.focusTaskInViewWhenReady(id, (el) => {
|
||
recordSearchNavDebug('appComponent:focusElement:success', {
|
||
taskId: id,
|
||
url: window.location.pathname + window.location.search,
|
||
matchedElementId: el.id,
|
||
});
|
||
if (el && IS_MOBILE) {
|
||
el.classList.add('mobile-highlight-searched-item');
|
||
el.addEventListener('blur', () =>
|
||
el.classList.remove('mobile-highlight-searched-item'),
|
||
);
|
||
}
|
||
});
|
||
}
|
||
}
|