fix: performance

This commit is contained in:
2026-04-28 19:05:22 +02:00
parent c6eb4fd452
commit 07000e539c
7 changed files with 253 additions and 20 deletions
+2 -2
View File
@@ -34,7 +34,7 @@
"input": "public" "input": "public"
} }
], ],
"styles": ["node_modules/@angular/material/prebuilt-themes/rose-red.css", "src/styles.scss"], "styles": ["src/styles.scss"],
"scripts": [], "scripts": [],
"server": "src/main.server.ts", "server": "src/main.server.ts",
"prerender": true, "prerender": true,
@@ -119,7 +119,7 @@
"output": "public" "output": "public"
} }
], ],
"styles": ["node_modules/@angular/material/prebuilt-themes/rose-red.css", "src/styles.scss"], "styles": ["src/styles.scss"],
"scripts": [] "scripts": []
} }
} }
+4
View File
@@ -12,6 +12,7 @@ import { RouterOutlet } from '@angular/router';
import type { LanguageCode } from '../languages/language.types'; import type { LanguageCode } from '../languages/language.types';
import type { LanguageOption } from './app.types'; import type { LanguageOption } from './app.types';
import { KofiWidgetService } from './services/kofi-widget.service';
import { LanguageService } from './services/language.service'; import { LanguageService } from './services/language.service';
@Component({ @Component({
@@ -23,6 +24,7 @@ import { LanguageService } from './services/language.service';
}) })
export class AppComponent implements OnInit, OnDestroy { export class AppComponent implements OnInit, OnDestroy {
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
private readonly kofiWidgetService = inject(KofiWidgetService);
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
protected readonly content = this.languageService.content; protected readonly content = this.languageService.content;
@@ -53,6 +55,7 @@ export class AppComponent implements OnInit, OnDestroy {
const initialLanguage = this.languageService.initializeFromStorage(); const initialLanguage = this.languageService.initializeFromStorage();
this.selectedLanguage.set(initialLanguage); this.selectedLanguage.set(initialLanguage);
this.isLanguageSelectorOpen.set(!this.isLanguageConfirmed()); this.isLanguageSelectorOpen.set(!this.isLanguageConfirmed());
this.kofiWidgetService.scheduleLoad();
} }
protected chooseLanguage(language: LanguageCode): void { protected chooseLanguage(language: LanguageCode): void {
@@ -90,6 +93,7 @@ export class AppComponent implements OnInit, OnDestroy {
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.kofiWidgetService.teardown();
this.document.body.style.overflow = ''; this.document.body.style.overflow = '';
this.document.documentElement.style.overflow = ''; this.document.documentElement.style.overflow = '';
} }
+11 -5
View File
@@ -1,10 +1,16 @@
import type { Routes } from '@angular/router'; import type { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { ImprintComponent } from './imprint/imprint.component';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: HomeComponent, pathMatch: 'full' }, {
{ path: 'imprint', component: ImprintComponent, pathMatch: 'full' }, path: '',
loadComponent: () => import('./home/home.component').then((module) => module.HomeComponent),
pathMatch: 'full',
},
{
path: 'imprint',
loadComponent: () =>
import('./imprint/imprint.component').then((module) => module.ImprintComponent),
pathMatch: 'full',
},
{ path: '**', redirectTo: '' }, { path: '**', redirectTo: '' },
]; ];
+9 -3
View File
@@ -269,9 +269,15 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
const startY: number = viewportHeight * 0.98; const startY: number = viewportHeight * 0.98;
const endY: number = viewportHeight * 0.56; const endY: number = viewportHeight * 0.56;
const totalDistance: number = Math.max(startY - endY, 1); const totalDistance: number = Math.max(startY - endY, 1);
projectEntries.forEach((entry: HTMLElement): void => { const entriesWithTop: Array<{ entry: HTMLElement; top: number }> = Array.from(
const entryRect: DOMRect = entry.getBoundingClientRect(); projectEntries,
const rawProgress: number = (startY - entryRect.top) / totalDistance; (entry: HTMLElement) => ({
entry,
top: entry.getBoundingClientRect().top,
}),
);
entriesWithTop.forEach(({ entry, top }): void => {
const rawProgress: number = (startY - top) / totalDistance;
const clampedProgress: number = Math.min(Math.max(rawProgress, 0), 1); const clampedProgress: number = Math.min(Math.max(rawProgress, 0), 1);
entry.style.setProperty('--project-entry-progress', clampedProgress.toFixed(4)); entry.style.setProperty('--project-entry-progress', clampedProgress.toFixed(4));
}); });
+218
View File
@@ -0,0 +1,218 @@
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class KofiWidgetService {
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly widgetScriptUrl = 'https://storage.ko-fi.com/cdn/scripts/overlay-widget.js';
private readonly widgetIframeTitle = 'Ko-fi support chat';
private readonly fallbackDelayMs = 10000;
private loadTimeoutId: number | null = null;
private loadIdleCallbackId: number | null = null;
private loadListenerAbortController: AbortController | null = null;
private iframeObserver: MutationObserver | null = null;
private isWidgetScheduled = false;
private isWidgetLoaded = false;
private isWidgetDrawn = false;
scheduleLoad(): void {
if (!isPlatformBrowser(this.platformId) || this.isWidgetScheduled) {
return;
}
this.isWidgetScheduled = true;
const windowRef = this.document.defaultView;
if (windowRef === null) {
return;
}
const queueWidgetLoad = (): void => {
if (this.isWidgetLoaded || this.isWidgetDrawn) {
return;
}
const loadWidget = (): void => {
this.loadIdleCallbackId = null;
void this.loadWidget();
};
if (typeof windowRef.requestIdleCallback === 'function') {
this.loadIdleCallbackId = windowRef.requestIdleCallback(loadWidget, { timeout: 2500 });
return;
}
this.loadTimeoutId = windowRef.setTimeout(loadWidget, 1800);
};
const triggerWidgetLoad = (): void => {
this.clearStartListeners();
queueWidgetLoad();
};
const scheduleFallbackWidgetLoad = (): void => {
if (this.isWidgetLoaded || this.isWidgetDrawn) {
return;
}
this.loadTimeoutId = windowRef.setTimeout((): void => {
this.loadTimeoutId = null;
triggerWidgetLoad();
}, this.fallbackDelayMs);
};
this.loadListenerAbortController = new AbortController();
const listenerOptions: AddEventListenerOptions = {
passive: true,
signal: this.loadListenerAbortController.signal,
};
windowRef.addEventListener('pointerdown', triggerWidgetLoad, listenerOptions);
windowRef.addEventListener('keydown', triggerWidgetLoad, listenerOptions);
windowRef.addEventListener('touchstart', triggerWidgetLoad, listenerOptions);
windowRef.addEventListener('scroll', triggerWidgetLoad, listenerOptions);
if (this.document.readyState === 'complete') {
scheduleFallbackWidgetLoad();
} else {
windowRef.addEventListener('load', scheduleFallbackWidgetLoad, {
...listenerOptions,
once: true,
});
}
}
teardown(): void {
const windowRef = this.document.defaultView;
this.clearStartListeners();
if (windowRef !== null && this.loadTimeoutId !== null) {
windowRef.clearTimeout(this.loadTimeoutId);
this.loadTimeoutId = null;
}
if (
windowRef !== null &&
this.loadIdleCallbackId !== null &&
typeof windowRef.cancelIdleCallback === 'function'
) {
windowRef.cancelIdleCallback(this.loadIdleCallbackId);
this.loadIdleCallbackId = null;
}
this.iframeObserver?.disconnect();
this.iframeObserver = null;
}
private clearStartListeners(): void {
this.loadListenerAbortController?.abort();
this.loadListenerAbortController = null;
}
private async loadWidget(): Promise<void> {
if (this.isWidgetLoaded || this.isWidgetDrawn) {
return;
}
const windowRef = this.document.defaultView;
if (windowRef === null) {
return;
}
if (windowRef.kofiWidgetOverlay !== undefined) {
this.isWidgetLoaded = true;
this.drawWidget();
return;
}
const existingScript = this.document.querySelector<HTMLScriptElement>(
`script[src="${this.widgetScriptUrl}"]`,
);
if (existingScript !== null) {
existingScript.addEventListener(
'load',
(): void => {
this.isWidgetLoaded = true;
this.drawWidget();
},
{ once: true },
);
return;
}
await new Promise<void>((resolve, reject): void => {
const scriptElement = this.document.createElement('script');
scriptElement.src = this.widgetScriptUrl;
scriptElement.async = true;
scriptElement.crossOrigin = 'anonymous';
scriptElement.addEventListener(
'load',
(): void => {
this.isWidgetLoaded = true;
resolve();
},
{ once: true },
);
scriptElement.addEventListener(
'error',
(): void => reject(new Error('Failed to load the Ko-fi widget script.')),
{ once: true },
);
this.document.body.appendChild(scriptElement);
}).catch((): void => {
this.isWidgetLoaded = false;
});
this.drawWidget();
}
private drawWidget(): void {
if (this.isWidgetDrawn) {
return;
}
const windowRef = this.document.defaultView;
if (windowRef?.kofiWidgetOverlay === undefined) {
return;
}
windowRef.kofiWidgetOverlay.draw('fraujulian', {
type: 'floating-chat',
'floating-chat.donateButton.text': 'Coffee',
'floating-chat.donateButton.background-color': '#ff5f5f',
'floating-chat.donateButton.text-color': '#fff',
});
this.isWidgetDrawn = true;
this.ensureAccessibleIframeTitles();
this.observeInjectedIframes();
}
private ensureAccessibleIframeTitles(): void {
this.document
.querySelectorAll<HTMLIFrameElement>('iframe[id^="kofi-wo-container"]')
.forEach((iframe: HTMLIFrameElement): void => {
iframe.title = this.widgetIframeTitle;
});
}
private observeInjectedIframes(): void {
if (this.iframeObserver !== null) {
return;
}
this.iframeObserver = new MutationObserver((): void => {
this.ensureAccessibleIframeTitles();
});
this.iframeObserver.observe(this.document.body, {
childList: true,
subtree: true,
});
}
}
-10
View File
@@ -17,15 +17,5 @@
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
<script src="https://storage.ko-fi.com/cdn/scripts/overlay-widget.js"></script>
<script>
kofiWidgetOverlay.draw('fraujulian', {
type: 'floating-chat',
'floating-chat.donateButton.text': 'Coffee',
'floating-chat.donateButton.background-color': '#ff5f5f',
'floating-chat.donateButton.text-color': '#fff',
});
</script>
</body> </body>
</html> </html>
+9
View File
@@ -0,0 +1,9 @@
interface KofiWidgetOverlay {
draw(userName: string, options: Record<string, string>): void;
}
interface Window {
kofiWidgetOverlay?: KofiWidgetOverlay;
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
cancelIdleCallback?: (handle: number) => void;
}