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>
This commit is contained in:
parent
db99a4c345
commit
393f686e4b
11 changed files with 476 additions and 85 deletions
|
|
@ -1,36 +0,0 @@
|
|||
import { getBackgroundImageBlur, getBackgroundOverlayOpacity } from './app.component';
|
||||
|
||||
describe('AppComponent theme helpers', () => {
|
||||
describe('getBackgroundOverlayOpacity()', () => {
|
||||
it('should use the default overlay opacity when the active context is missing', () => {
|
||||
expect(getBackgroundOverlayOpacity(null)).toBe(0.2);
|
||||
expect(getBackgroundOverlayOpacity(undefined)).toBe(0.2);
|
||||
});
|
||||
|
||||
it('should use the default overlay opacity when a persisted context has a null theme', () => {
|
||||
expect(getBackgroundOverlayOpacity({ theme: null })).toBe(0.2);
|
||||
});
|
||||
|
||||
it('should resolve configured overlay opacity to a CSS alpha value', () => {
|
||||
expect(
|
||||
getBackgroundOverlayOpacity({ theme: { backgroundOverlayOpacity: 65 } }),
|
||||
).toBe(0.65);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBackgroundImageBlur()', () => {
|
||||
it('should use zero blur when the active context is missing', () => {
|
||||
expect(getBackgroundImageBlur(null)).toBe(0);
|
||||
expect(getBackgroundImageBlur(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('should use zero blur when a persisted context has a null theme', () => {
|
||||
expect(getBackgroundImageBlur({ theme: null })).toBe(0);
|
||||
});
|
||||
|
||||
it('should normalize configured blur values', () => {
|
||||
expect(getBackgroundImageBlur({ theme: { backgroundImageBlur: 12 } })).toBe(12);
|
||||
expect(getBackgroundImageBlur({ theme: { backgroundImageBlur: -5 } })).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -64,14 +64,10 @@ import { SuperSyncEncryptionMigrationBannerService } from './imex/sync/super-syn
|
|||
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,
|
||||
type WorkContextThemeCfg,
|
||||
} from './features/work-context/work-context.model';
|
||||
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 { normalizeBackgroundImageBlur } from './features/work-context/work-context.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';
|
||||
|
|
@ -95,21 +91,6 @@ interface BeforeInstallPromptEvent extends Event {
|
|||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
type WorkContextThemeSource =
|
||||
| {
|
||||
theme?: WorkContextThemeCfg | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export const getBackgroundOverlayOpacity = (context: WorkContextThemeSource): number => {
|
||||
const baseOpacity = context?.theme?.backgroundOverlayOpacity ?? 20;
|
||||
return baseOpacity * 0.01;
|
||||
};
|
||||
|
||||
export const getBackgroundImageBlur = (context: WorkContextThemeSource): number =>
|
||||
normalizeBackgroundImageBlur(context?.theme?.backgroundImageBlur);
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
|
|
@ -226,10 +207,6 @@ export class AppComponent implements OnDestroy, AfterViewInit {
|
|||
{ initialValue: null },
|
||||
);
|
||||
|
||||
private readonly _activeWorkContext = toSignal(
|
||||
this.workContextService.activeWorkContext$,
|
||||
{ initialValue: null },
|
||||
);
|
||||
readonly resolvedBgImage = signal<string | null>(null);
|
||||
|
||||
isShowOnboardingPresets = signal(
|
||||
|
|
@ -449,13 +426,12 @@ export class AppComponent implements OnDestroy, AfterViewInit {
|
|||
this.layoutService.scrollToNewTask(taskId);
|
||||
}
|
||||
|
||||
readonly bgOverlayOpacity = computed((): number => {
|
||||
return getBackgroundOverlayOpacity(this._activeWorkContext());
|
||||
});
|
||||
|
||||
readonly bgImageBlur = computed((): number => {
|
||||
return getBackgroundImageBlur(this._activeWorkContext());
|
||||
});
|
||||
// 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();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import {
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
|
||||
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { T } from '../../../t.const';
|
||||
import { GlobalConfigService } from '../../../features/config/global-config.service';
|
||||
import { MiscConfig } from '../../../features/config/global-config.model';
|
||||
import {
|
||||
DEFAULT_BACKGROUND_OVERLAY_OPACITY,
|
||||
hasAnyBackgroundImage,
|
||||
MAX_BACKGROUND_IMAGE_BLUR,
|
||||
} from '../../../features/work-context/work-context.const';
|
||||
import { GlobalWallpaperCfg } from '../global-theme.service';
|
||||
|
||||
// Mutable copy of the global wallpaper shape so Formly can write to the model.
|
||||
type WallpaperModel = {
|
||||
-readonly [K in keyof GlobalWallpaperCfg]: GlobalWallpaperCfg[K];
|
||||
};
|
||||
|
||||
// Mirrors the image-input + slider controls of the per-context theme form
|
||||
// (work-context.const.ts); built fresh per dialog open because Formly mutates
|
||||
// field configs at runtime. Opacity/blur stay hidden until an image is set.
|
||||
const buildWallpaperFields = (): FormlyFieldConfig[] => [
|
||||
{
|
||||
key: 'backgroundImageDark',
|
||||
type: 'image-input',
|
||||
templateOptions: {
|
||||
label: T.F.PROJECT.FORM_THEME.L_BACKGROUND_IMAGE_DARK,
|
||||
description: '* https://some/cool.jpg',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'backgroundImageLight',
|
||||
type: 'image-input',
|
||||
templateOptions: {
|
||||
label: T.F.PROJECT.FORM_THEME.L_BACKGROUND_IMAGE_LIGHT,
|
||||
description: '* https://some/cool.jpg',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'backgroundOverlayOpacity',
|
||||
type: 'slider',
|
||||
resetOnHide: false,
|
||||
props: {
|
||||
label: T.F.PROJECT.FORM_THEME.L_BACKGROUND_OVERLAY_OPACITY,
|
||||
description: T.F.PROJECT.FORM_THEME.D_BACKGROUND_OVERLAY_OPACITY,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 99,
|
||||
required: false,
|
||||
displayWith: (value: number): string => `${value}%`,
|
||||
},
|
||||
expressions: {
|
||||
hide: (field: FormlyFieldConfig): boolean => !hasAnyBackgroundImage(field.model),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'backgroundImageBlur',
|
||||
type: 'slider',
|
||||
resetOnHide: false,
|
||||
props: {
|
||||
label: T.F.PROJECT.FORM_THEME.L_BACKGROUND_IMAGE_BLUR,
|
||||
description: T.F.PROJECT.FORM_THEME.D_BACKGROUND_IMAGE_BLUR,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: MAX_BACKGROUND_IMAGE_BLUR,
|
||||
required: false,
|
||||
displayWith: (value: number): string => `${value}px`,
|
||||
},
|
||||
expressions: {
|
||||
hide: (field: FormlyFieldConfig): boolean => !hasAnyBackgroundImage(field.model),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-wallpaper',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatButton,
|
||||
ReactiveFormsModule,
|
||||
FormlyModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<h1 mat-dialog-title>{{ T.GCF.MISC.WALLPAPER_DIALOG_TITLE | translate }}</h1>
|
||||
<mat-dialog-content>
|
||||
<p class="wallpaper-hint">{{ T.GCF.MISC.WALLPAPER_HINT | translate }}</p>
|
||||
<form [formGroup]="form">
|
||||
<formly-form
|
||||
[form]="form"
|
||||
[fields]="fields"
|
||||
[model]="model"
|
||||
(modelChange)="model = $event"
|
||||
></formly-form>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="close()"
|
||||
>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
(click)="save()"
|
||||
>
|
||||
{{ T.G.SAVE | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.wallpaper-hint {
|
||||
margin: 0 0 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class DialogWallpaperComponent {
|
||||
private _dialogRef = inject<MatDialogRef<DialogWallpaperComponent>>(MatDialogRef);
|
||||
private _globalConfigService = inject(GlobalConfigService);
|
||||
|
||||
readonly T = T;
|
||||
readonly form = new UntypedFormGroup({});
|
||||
readonly fields = buildWallpaperFields();
|
||||
|
||||
model: WallpaperModel = ((): WallpaperModel => {
|
||||
const misc = this._globalConfigService.misc();
|
||||
return {
|
||||
backgroundImageDark: misc?.backgroundImageDark ?? null,
|
||||
backgroundImageLight: misc?.backgroundImageLight ?? null,
|
||||
backgroundOverlayOpacity:
|
||||
misc?.backgroundOverlayOpacity ?? DEFAULT_BACKGROUND_OVERLAY_OPACITY,
|
||||
backgroundImageBlur: misc?.backgroundImageBlur ?? 0,
|
||||
};
|
||||
})();
|
||||
|
||||
save(): void {
|
||||
const changes: Partial<MiscConfig> = {
|
||||
// A cleared picker stores '' — normalize to null so the fallback chain
|
||||
// (context → global → none) treats it as unset.
|
||||
backgroundImageDark: this.model.backgroundImageDark || null,
|
||||
backgroundImageLight: this.model.backgroundImageLight || null,
|
||||
backgroundOverlayOpacity: this.model.backgroundOverlayOpacity,
|
||||
backgroundImageBlur: this.model.backgroundImageBlur,
|
||||
};
|
||||
this._globalConfigService.updateSection('misc', changes);
|
||||
this._dialogRef.close();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this._dialogRef.close();
|
||||
}
|
||||
}
|
||||
133
src/app/core/theme/global-theme.service.spec.ts
Normal file
133
src/app/core/theme/global-theme.service.spec.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { type WorkContextThemeCfg } from '../../features/work-context/work-context.model';
|
||||
import { GlobalWallpaperCfg, resolveBackground } from './global-theme.service';
|
||||
|
||||
describe('resolveBackground()', () => {
|
||||
const contextTheme: WorkContextThemeCfg = {
|
||||
backgroundImageDark: 'ctx-dark.jpg',
|
||||
backgroundImageLight: 'ctx-light.jpg',
|
||||
backgroundOverlayOpacity: 50,
|
||||
backgroundImageBlur: 8,
|
||||
};
|
||||
const contextThemeWithoutImage: WorkContextThemeCfg = {
|
||||
backgroundImageDark: null,
|
||||
backgroundImageLight: null,
|
||||
backgroundOverlayOpacity: 50,
|
||||
backgroundImageBlur: 8,
|
||||
};
|
||||
const globalCfg: GlobalWallpaperCfg = {
|
||||
backgroundImageDark: 'global-dark.jpg',
|
||||
backgroundImageLight: 'global-light.jpg',
|
||||
backgroundOverlayOpacity: 30,
|
||||
backgroundImageBlur: 4,
|
||||
};
|
||||
const globalCfgEmpty: GlobalWallpaperCfg = {
|
||||
backgroundImageDark: null,
|
||||
backgroundImageLight: null,
|
||||
};
|
||||
|
||||
describe('before initial navigation resolves (empty url)', () => {
|
||||
it('shows no background when no global wallpaper is set', () => {
|
||||
expect(
|
||||
resolveBackground(contextTheme, globalCfgEmpty, false, '').imageUrl,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the global wallpaper when set (never the sticky context image)', () => {
|
||||
const res = resolveBackground(contextTheme, globalCfg, false, '');
|
||||
expect(res.imageUrl).toBe('global-light.jpg');
|
||||
// global styling, not the sticky context's
|
||||
expect(res.overlayOpacity).toBe(0.3);
|
||||
expect(res.blur).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
['/planner', '/schedule', '/boards', '/config'].forEach((url) => {
|
||||
it(`never uses the active context image on non-context route ${url}`, () => {
|
||||
expect(
|
||||
resolveBackground(contextTheme, globalCfgEmpty, false, url).imageUrl,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it(`shows the global wallpaper + global styling on ${url}`, () => {
|
||||
const light = resolveBackground(contextTheme, globalCfg, false, url);
|
||||
expect(light.imageUrl).toBe('global-light.jpg');
|
||||
expect(light.overlayOpacity).toBe(0.3);
|
||||
expect(light.blur).toBe(4);
|
||||
|
||||
const dark = resolveBackground(contextTheme, globalCfg, true, url);
|
||||
expect(dark.imageUrl).toBe('global-dark.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
['/tag/TODAY/tasks', '/project/project-1/tasks'].forEach((url) => {
|
||||
it(`uses the context image + context styling on ${url}`, () => {
|
||||
const res = resolveBackground(contextTheme, globalCfg, false, url);
|
||||
expect(res.imageUrl).toBe('ctx-light.jpg');
|
||||
// styling must come from the context that owns the image
|
||||
expect(res.overlayOpacity).toBe(0.5);
|
||||
expect(res.blur).toBe(8);
|
||||
});
|
||||
|
||||
it(`falls back to the global wallpaper + global styling on ${url} when the context has no image`, () => {
|
||||
const res = resolveBackground(contextThemeWithoutImage, globalCfg, false, url);
|
||||
expect(res.imageUrl).toBe('global-light.jpg');
|
||||
expect(res.overlayOpacity).toBe(0.3);
|
||||
expect(res.blur).toBe(4);
|
||||
});
|
||||
|
||||
it(`treats a cleared (empty-string) context image as unset and falls back to global on ${url}`, () => {
|
||||
const cleared: WorkContextThemeCfg = {
|
||||
backgroundImageLight: ' ',
|
||||
backgroundImageDark: '',
|
||||
};
|
||||
expect(resolveBackground(cleared, globalCfg, false, url).imageUrl).toBe(
|
||||
'global-light.jpg',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not misclassify a non-context route that mentions /tag/ in its query', () => {
|
||||
// regex is anchored to the path start
|
||||
expect(
|
||||
resolveBackground(contextTheme, globalCfgEmpty, false, '/planner?ref=/tag/TODAY')
|
||||
.imageUrl,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the dark context image in dark mode', () => {
|
||||
expect(
|
||||
resolveBackground(contextTheme, globalCfgEmpty, true, '/tag/TODAY/tasks').imageUrl,
|
||||
).toBe('ctx-dark.jpg');
|
||||
});
|
||||
|
||||
describe('overlay-opacity + blur math (migrated from app.component helpers)', () => {
|
||||
it('defaults to 20% overlay / 0 blur when the winning source sets neither', () => {
|
||||
const res = resolveBackground(
|
||||
{},
|
||||
{ backgroundImageLight: 'g.jpg' },
|
||||
false,
|
||||
'/planner',
|
||||
);
|
||||
expect(res.overlayOpacity).toBe(0.2);
|
||||
expect(res.blur).toBe(0);
|
||||
});
|
||||
|
||||
it('converts overlay opacity to a CSS alpha and clamps blur', () => {
|
||||
const highOpacity = resolveBackground(
|
||||
{ backgroundImageLight: 'c.jpg', backgroundOverlayOpacity: 65 },
|
||||
globalCfgEmpty,
|
||||
false,
|
||||
'/tag/TODAY/tasks',
|
||||
);
|
||||
expect(highOpacity.overlayOpacity).toBe(0.65);
|
||||
|
||||
const negativeBlur = resolveBackground(
|
||||
{ backgroundImageLight: 'c.jpg', backgroundImageBlur: -5 },
|
||||
globalCfgEmpty,
|
||||
false,
|
||||
'/tag/TODAY/tasks',
|
||||
);
|
||||
expect(negativeBlur.blur).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
EnvironmentInjector,
|
||||
|
|
@ -11,7 +12,15 @@ import {
|
|||
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { BodyClass, IS_ELECTRON, IS_GNOME_WAYLAND } from '../../app.constants';
|
||||
import { IS_MAC } from '../../util/is-mac';
|
||||
import { distinctUntilChanged, map, startWith, switchMap, take } from 'rxjs/operators';
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
} from 'rxjs/operators';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { IS_TOUCH_ONLY } from '../../util/is-touch-only';
|
||||
import { MaterialCssVarsService } from 'angular-material-css-vars';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
|
|
@ -21,6 +30,11 @@ import { ChromeExtensionInterfaceService } from '../chrome-extension-interface/c
|
|||
|
||||
import { GlobalConfigService } from '../../features/config/global-config.service';
|
||||
import { WorkContextThemeCfg } from '../../features/work-context/work-context.model';
|
||||
import {
|
||||
DEFAULT_BACKGROUND_OVERLAY_OPACITY,
|
||||
isBackgroundImageSet,
|
||||
normalizeBackgroundImageBlur,
|
||||
} from '../../features/work-context/work-context.const';
|
||||
import { WorkContextService } from '../../features/work-context/work-context.service';
|
||||
import { combineLatest, fromEvent, Observable, of } from 'rxjs';
|
||||
import { IS_FIREFOX } from '../../util/is-firefox';
|
||||
|
|
@ -65,6 +79,68 @@ const CSS_VAR_SAFE_AREA_LEFT = '--safe-area-inset-left';
|
|||
const CSS_VAR_SAFE_AREA_RIGHT = '--safe-area-inset-right';
|
||||
const VIEWPORT_RESIZE_EPSILON_PX = 1;
|
||||
|
||||
/** The four wallpaper fields of the app-level (global) background config. */
|
||||
export type GlobalWallpaperCfg = Pick<
|
||||
WorkContextThemeCfg,
|
||||
| 'backgroundImageDark'
|
||||
| 'backgroundImageLight'
|
||||
| 'backgroundOverlayOpacity'
|
||||
| 'backgroundImageBlur'
|
||||
>;
|
||||
|
||||
export interface ResolvedBackground {
|
||||
/** The image to show, or null when no background applies to this URL. */
|
||||
imageUrl: string | null;
|
||||
/** Final CSS overlay opacity (0..0.99) of the resolved image's source. */
|
||||
overlayOpacity: number;
|
||||
/** Blur radius in px of the resolved image's source. */
|
||||
blur: number;
|
||||
}
|
||||
|
||||
const _styleOf = (
|
||||
theme: GlobalWallpaperCfg,
|
||||
): Pick<ResolvedBackground, 'overlayOpacity' | 'blur'> => ({
|
||||
overlayOpacity:
|
||||
(theme.backgroundOverlayOpacity ?? DEFAULT_BACKGROUND_OVERLAY_OPACITY) * 0.01,
|
||||
blur: normalizeBackgroundImageBlur(theme.backgroundImageBlur),
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve which background image and styling apply to the current route.
|
||||
*
|
||||
* Precedence: per-context image → global wallpaper → none. Crucially, on
|
||||
* non-work-context routes (Planner, Schedule, Boards, Config, …) the active
|
||||
* work context stays "Today" (the reducer default), so we must never use its
|
||||
* image there — only the global wallpaper. The overlay-opacity and blur travel
|
||||
* with the resolved image so a global wallpaper is never styled by the sticky
|
||||
* context's settings.
|
||||
*/
|
||||
export const resolveBackground = (
|
||||
contextTheme: WorkContextThemeCfg,
|
||||
globalCfg: GlobalWallpaperCfg,
|
||||
isDarkMode: boolean,
|
||||
url: string,
|
||||
): ResolvedBackground => {
|
||||
// Anchor to the path start so a query/fragment containing "/tag/" or
|
||||
// "/project/" can't misclassify a non-context route.
|
||||
const isWorkContextUrl = /^\/(tag|project)\//.test(url);
|
||||
const contextImg = isDarkMode
|
||||
? contextTheme.backgroundImageDark
|
||||
: contextTheme.backgroundImageLight;
|
||||
|
||||
if (isWorkContextUrl && isBackgroundImageSet(contextImg)) {
|
||||
return { imageUrl: contextImg, ..._styleOf(contextTheme) };
|
||||
}
|
||||
|
||||
const globalImg = isDarkMode
|
||||
? globalCfg.backgroundImageDark
|
||||
: globalCfg.backgroundImageLight;
|
||||
return {
|
||||
imageUrl: isBackgroundImageSet(globalImg) ? globalImg : null,
|
||||
..._styleOf(globalCfg),
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GlobalThemeService {
|
||||
private document = inject<Document>(DOCUMENT);
|
||||
|
|
@ -75,6 +151,7 @@ export class GlobalThemeService {
|
|||
private _matIconRegistry = inject(MatIconRegistry);
|
||||
private readonly _registeredPluginIcons = new Set<string>();
|
||||
private _domSanitizer = inject(DomSanitizer);
|
||||
private _router = inject(Router);
|
||||
|
||||
private _chromeExtensionInterfaceService = inject(ChromeExtensionInterfaceService);
|
||||
private _imexMetaService = inject(ImexViewService);
|
||||
|
|
@ -134,17 +211,47 @@ export class GlobalThemeService {
|
|||
|
||||
isDarkTheme = toSignal(this._isDarkThemeObs$, { initialValue: false });
|
||||
|
||||
private _backgroundImgObs$: Observable<string | null | undefined> = combineLatest([
|
||||
this._workContextService.currentTheme$,
|
||||
this._isDarkThemeObs$,
|
||||
]).pipe(
|
||||
map(([theme, isDarkMode]) =>
|
||||
isDarkMode ? theme.backgroundImageDark : theme.backgroundImageLight,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
// Emits the current URL after each completed navigation, starting with the
|
||||
// current URL so the stream is immediately available before any navigation.
|
||||
private _currentUrl$: Observable<string> = this._router.events.pipe(
|
||||
filter((e) => e instanceof NavigationEnd),
|
||||
map((e) => (e as NavigationEnd).urlAfterRedirects),
|
||||
startWith(this._router.url),
|
||||
);
|
||||
|
||||
backgroundImg = toSignal(this._backgroundImgObs$);
|
||||
private _resolvedBackground$: Observable<ResolvedBackground> = combineLatest([
|
||||
this._workContextService.currentTheme$,
|
||||
this._isDarkThemeObs$,
|
||||
this._currentUrl$,
|
||||
this._globalConfigService.misc$,
|
||||
]).pipe(
|
||||
map(([theme, isDarkMode, url, misc]) =>
|
||||
resolveBackground(theme, misc, isDarkMode, url),
|
||||
),
|
||||
distinctUntilChanged(
|
||||
(a, b) =>
|
||||
a.imageUrl === b.imageUrl &&
|
||||
a.overlayOpacity === b.overlayOpacity &&
|
||||
a.blur === b.blur,
|
||||
),
|
||||
);
|
||||
|
||||
private _resolvedBackground = toSignal(this._resolvedBackground$, {
|
||||
initialValue: {
|
||||
imageUrl: null,
|
||||
overlayOpacity: DEFAULT_BACKGROUND_OVERLAY_OPACITY * 0.01,
|
||||
blur: 0,
|
||||
} satisfies ResolvedBackground,
|
||||
});
|
||||
|
||||
/** The resolved background image URL for the current route (null if none). */
|
||||
readonly backgroundImg = computed(() => this._resolvedBackground().imageUrl);
|
||||
|
||||
/** Final CSS overlay opacity for the resolved background image. */
|
||||
readonly bgOverlayOpacity = computed(() => this._resolvedBackground().overlayOpacity);
|
||||
|
||||
/** Blur radius (px) for the resolved background image. */
|
||||
readonly bgImageBlur = computed(() => this._resolvedBackground().blur);
|
||||
|
||||
init(): void {
|
||||
if (this._hasInitialized) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ import { MatSelect, MatSelectChange } from '@angular/material/select';
|
|||
import { MatOption } from '@angular/material/core';
|
||||
import { MatFormField, MatLabel } from '@angular/material/form-field';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { GlobalThemeService } from '../global-theme.service';
|
||||
import { DialogWallpaperComponent } from '../dialog-wallpaper/dialog-wallpaper.component';
|
||||
import { CustomTheme, CustomThemeRef, CustomThemeService } from '../custom-theme.service';
|
||||
import { ThemeStorageService } from '../theme-storage.service';
|
||||
import { SnackService } from '../../snack/snack.service';
|
||||
|
|
@ -129,6 +131,18 @@ const valueToRef = (value: string): CustomThemeRef => {
|
|||
(change)="onFileSelected($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="wallpaper-select">
|
||||
<h3>{{ T.GCF.MISC.WALLPAPER | translate }}</h3>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openWallpaperDialog()"
|
||||
>
|
||||
<mat-icon>wallpaper</mat-icon>
|
||||
{{ T.GCF.MISC.WALLPAPER_BUTTON | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
|
|
@ -141,7 +155,8 @@ const valueToRef = (value: string): CustomThemeRef => {
|
|||
}
|
||||
|
||||
.dark-mode-select,
|
||||
.theme-select {
|
||||
.theme-select,
|
||||
.wallpaper-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
|
@ -182,7 +197,8 @@ const valueToRef = (value: string): CustomThemeRef => {
|
|||
|
||||
@media (max-width: 600px) {
|
||||
.dark-mode-select,
|
||||
.theme-select {
|
||||
.theme-select,
|
||||
.wallpaper-select {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
|
@ -200,6 +216,7 @@ export class ThemeSelectorComponent {
|
|||
readonly customThemeService = inject(CustomThemeService);
|
||||
private readonly _themeStorage = inject(ThemeStorageService);
|
||||
private readonly _snackService = inject(SnackService);
|
||||
private readonly _matDialog = inject(MatDialog);
|
||||
readonly T = T;
|
||||
|
||||
readonly fileInput = viewChild<ElementRef<HTMLInputElement>>('fileInput');
|
||||
|
|
@ -234,6 +251,10 @@ export class ThemeSelectorComponent {
|
|||
this.fileInput()?.nativeElement.click();
|
||||
}
|
||||
|
||||
openWallpaperDialog(): void {
|
||||
this._matDialog.open(DialogWallpaperComponent, { autoFocus: false });
|
||||
}
|
||||
|
||||
async onFileSelected(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
isShowProductivityTipLonger: false,
|
||||
customTheme: 'default',
|
||||
defaultStartPage: 0,
|
||||
backgroundImageDark: null,
|
||||
backgroundImageLight: null,
|
||||
},
|
||||
shortSyntax: {
|
||||
isEnableProject: true,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ export type MiscConfig = Readonly<{
|
|||
// number: one of DefaultStartPage. string: project id.
|
||||
defaultStartPage?: number | string;
|
||||
unsplashApiKey?: string | null;
|
||||
// Global wallpaper: shown on pages that don't provide their own background
|
||||
// (Planner, Schedule, Boards, Config, and tags/projects without an image).
|
||||
backgroundImageDark?: string | null;
|
||||
backgroundImageLight?: string | null;
|
||||
backgroundOverlayOpacity?: number;
|
||||
backgroundImageBlur?: number;
|
||||
|
||||
// @todo: remove deprecated items in future major releases, after giving users time to migrate
|
||||
isConfirmBeforeTaskDelete?: boolean; // Deprecated
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const DEFAULT_TAG_COLOR = '#a05db1';
|
|||
export const DEFAULT_TODAY_TAG_COLOR = '#6495ED';
|
||||
export const DEFAULT_BACKGROUND_IMAGE_BLUR = 0;
|
||||
export const MAX_BACKGROUND_IMAGE_BLUR = 20;
|
||||
export const DEFAULT_BACKGROUND_OVERLAY_OPACITY = 20;
|
||||
|
||||
export const WORK_CONTEXT_DEFAULT_THEME: WorkContextThemeCfg = {
|
||||
isAutoContrast: true,
|
||||
|
|
@ -61,20 +62,22 @@ export const HUES = [
|
|||
{ value: '900', label: '900' },
|
||||
];
|
||||
|
||||
const _isBackgroundImageSet = (value: unknown): boolean =>
|
||||
// A cleared image picker stores '' rather than null, so treat empty/whitespace
|
||||
// strings as "no image set".
|
||||
export const isBackgroundImageSet = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
export const hasAnyBackgroundImage = (model: unknown): boolean =>
|
||||
typeof model === 'object' &&
|
||||
model !== null &&
|
||||
(_isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageDark) ||
|
||||
_isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageLight));
|
||||
(isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageDark) ||
|
||||
isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageLight));
|
||||
|
||||
export const hasAllBackgroundImages = (model: unknown): boolean =>
|
||||
typeof model === 'object' &&
|
||||
model !== null &&
|
||||
_isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageDark) &&
|
||||
_isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageLight);
|
||||
isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageDark) &&
|
||||
isBackgroundImageSet((model as WorkContextThemeCfg).backgroundImageLight);
|
||||
|
||||
export const normalizeBackgroundImageBlur = (value: unknown): number => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
|
|
|
|||
|
|
@ -2592,6 +2592,10 @@ const T = {
|
|||
THEME_REMOVED_TOAST: 'GCF.MISC.THEME_REMOVED_TOAST',
|
||||
THEME_SELECT_LABEL: 'GCF.MISC.THEME_SELECT_LABEL',
|
||||
TITLE: 'GCF.MISC.TITLE',
|
||||
WALLPAPER: 'GCF.MISC.WALLPAPER',
|
||||
WALLPAPER_BUTTON: 'GCF.MISC.WALLPAPER_BUTTON',
|
||||
WALLPAPER_DIALOG_TITLE: 'GCF.MISC.WALLPAPER_DIALOG_TITLE',
|
||||
WALLPAPER_HINT: 'GCF.MISC.WALLPAPER_HINT',
|
||||
},
|
||||
POMODORO: {
|
||||
BREAK_DURATION: 'GCF.POMODORO.BREAK_DURATION',
|
||||
|
|
|
|||
|
|
@ -2531,7 +2531,11 @@
|
|||
"THEME_REMOVE_BUTTON": "Remove theme",
|
||||
"THEME_REMOVED_TOAST": "Theme removed; reverted to Default.",
|
||||
"THEME_SELECT_LABEL": "Select Theme",
|
||||
"TITLE": "Misc Settings"
|
||||
"TITLE": "Misc Settings",
|
||||
"WALLPAPER": "Wallpaper",
|
||||
"WALLPAPER_BUTTON": "Set wallpaper…",
|
||||
"WALLPAPER_DIALOG_TITLE": "Global wallpaper",
|
||||
"WALLPAPER_HINT": "Shown on pages without their own background (Planner, Schedule, Boards, Settings…). Tags and projects with their own image override it."
|
||||
},
|
||||
"POMODORO": {
|
||||
"BREAK_DURATION": "Short break duration",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue