super-productivity/src/app/app.component.ts
jibin jose 393f686e4b
feat(theme): add global wallpaper with per-context override (#8643) (#8663)
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>
2026-07-02 12:53:38 +02:00

556 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 dont 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'),
);
}
});
}
}