Source

lib/services/NgxMediaService.ts

/**
 * @module NgxMediaService
 * @description Provides utilities for managing media-related features such as color scheme detection,
 * window resize observation, and SVG injection.
 * @summary
 * This module exposes the `NgxMediaService` class, which includes methods for observing the
 * application's color scheme, handling window resize events, and dynamically injecting SVG content
 * into the DOM. It leverages Angular's dependency injection system and RxJS for reactive programming.
 *
 * Key exports:
 * - {@link NgxMediaService}: The main service class providing media-related utilities.
 */

import { ElementRef, Injectable, NgZone,  inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { fromEvent, Subject, Observable, BehaviorSubject, merge, of, timer } from 'rxjs';
import { distinctUntilChanged, map, shareReplay, takeUntil, tap,  switchMap } from 'rxjs/operators';
import { IWindowResizeEvent } from '../engine/interfaces';
import { WindowColorScheme } from '../engine/types';
import { getWindow, getWindowDocument } from '../utils/helpers';
import { AngularEngineKeys, WindowColorSchemes } from '../engine/constants';




/**
 * @description Service for managing media-related features in an Angular application.
 * @summary
 * The `NgxMediaService` provides utilities for observing and managing media-related features
 * such as color scheme detection, window resize events, and dynamic SVG injection. It leverages
 * Angular's dependency injection system and RxJS for reactive programming.
 *
 * @class NgxMediaService
 * @example
 * // Inject the service into a component
 * constructor(private mediaService: NgxMediaService) {}
 *
 * // Observe the current color scheme
 * this.mediaService.colorScheme$.subscribe(scheme => {
 *   console.log('Current color scheme:', scheme);
 * });
 *
 * // Observe window resize events
 * this.mediaService.resize$.subscribe(dimensions => {
 *   console.log('Window dimensions:', dimensions);
 * });
 */
@Injectable({
  providedIn: 'root',
})
export class NgxMediaService {

  /**
   * @description Subject used to signal the destruction of the service.
   * @summary
   * This subject is used to complete observables and clean up resources when the service is destroyed.
   * It is utilized with the `takeUntil` operator to manage subscriptions.
   */
  private readonly destroy$ = new Subject<void>();

  /**
   * @description BehaviorSubject to track the window's dimensions.
   * @summary
   * This subject holds the current dimensions of the window (width and height) and emits updates
   * whenever the window is resized. It is used to provide the `resize$` observable.
   */
  private resizeSubject = new BehaviorSubject<IWindowResizeEvent>({
    width: this._window.innerWidth,
    height: this._window.innerHeight
  });

  /**
   * @description Angular's NgZone service for running code outside Angular's zone.
   * @summary
   * This service is used to optimize performance by running certain operations outside
   * Angular's zone and bringing updates back into the zone when necessary.
   */
  private readonly angularZone: NgZone = inject(NgZone);

  /**
   * @description Angular's HttpClient service for making HTTP requests.
   * @summary
   * This service is used to fetch resources such as SVG files for injection into the DOM.
   */
  // private http = inject(HttpClient);

  /**
   * @description Tracks the current color scheme of the application.
   * @summary
   * This property holds the current color scheme (light, dark, or undefined) and is updated
   * whenever the color scheme changes.
   */
  private currentSchema: WindowColorScheme = WindowColorSchemes.undefined;

  /**
   * @description Observable that emits the current color scheme of the application.
   * @summary
   * This observable emits the current color scheme (light or dark) and updates whenever
   * the system's color scheme preference changes or the `DARK_PALETTE_CLASS` is toggled.
   */
  readonly colorScheme$: Observable<WindowColorScheme> = this.colorSchemeObserver();

  /**
   * @description Observable that emits the current dimensions of the window.
   * @summary
   * This observable emits the current dimensions of the window (width and height) and updates
   * whenever the window is resized.
   */
  readonly resize$: Observable<IWindowResizeEvent> = this.resizeSubject.asObservable();

  /**
   * @description Retrieves the global `window` object.
   * @summary
   * This getter provides access to the global `window` object, ensuring it is properly typed.
   *
   * @return {Window} The global `window` object.
   */
  private get _window(): Window {
    return getWindow() as Window;
  }

  /**
   * @description Retrieves the global `document` object.
   * @summary
   * This getter provides access to the global `document` object, ensuring it is properly typed.
   *
   * @return {Document} The global `document` object.
   */
  private get _document(): Document {
    return getWindowDocument() as Document;
  }

  /**
   * @description Observes window resize events and emits the updated dimensions.
   * @summary
   * This method listens for window resize events and emits the new dimensions
   * (width and height) through the `resize$` observable. The resize events are
   * processed outside Angular's zone to improve performance, and updates are
   * brought back into the Angular zone to ensure change detection.
   *
   * @return {Observable<IWindowResizeEvent>} An observable that emits the window dimensions on resize.
   * @function windowResizeObserver
   * @example
   * mediaService.windowResizeObserver().subscribe(dimensions => {
   *   console.log('Window dimensions:', dimensions);
   * });
   */
  windowResizeObserver(): Observable<IWindowResizeEvent> {
    const win = this._window;
    this.angularZone.runOutsideAngular(() => {
      fromEvent(win, 'resize')
        .pipe(
          distinctUntilChanged(),
          takeUntil(this.destroy$),
          shareReplay(1)
        )
        .subscribe(() => {
          const dimensions: IWindowResizeEvent = {
            width: win.innerWidth,
            height: win.innerHeight
          };
          // update within the zone to reflect in Angular
          this.angularZone.run(() => this.resizeSubject.next(dimensions));
        });
    });

    return this.resize$;
  }

  /**
   * @description Observes the color scheme of the application.
   * @summary
   * This method observes changes in the system's color scheme preference (light or dark)
   * and the presence of the `DARK_PALETTE_CLASS` on the document's root element.
   * Optionally, it can toggle the dark mode class on a specific component.
   *
   * @param {ElementRef | HTMLElement} [component] - Optional component to toggle the dark mode class on.
   * @return {Observable<WindowColorScheme>} An observable that emits the current color scheme (`dark` or `light`).
   * @function colorSchemeObserver
   * @example
   * // Observe the color scheme globally
   * mediaService.colorSchemeObserver().subscribe(scheme => {
   *   console.log('Current color scheme:', scheme);
   * });
   *
   * // Observe and toggle dark mode on a specific component
   * const component = document.querySelector('.my-component');
   * mediaService.colorSchemeObserver(component).subscribe();
   */
  colorSchemeObserver(component?: ElementRef | HTMLElement): Observable<WindowColorScheme> {
    const win = this._window;
    const doc = this._document;
    const documentElement = doc.documentElement;
    const mediaFn =  win.matchMedia(`(prefers-color-scheme: ${WindowColorSchemes.dark})`);

    if(component) {
      this.colorScheme$.subscribe((schema: WindowColorScheme) => {
        this.toggleClass(
          component,
          AngularEngineKeys.DARK_PALETTE_CLASS,
          schema === WindowColorSchemes.dark ? true : false
        );
      });
    }

    return merge(
      of(mediaFn.matches ? WindowColorSchemes.dark: WindowColorSchemes.light),
      // observe media query changes
      new Observable<WindowColorScheme>(observer => {
        this.angularZone.runOutsideAngular(() => {
          const mediaQuery = mediaFn;
          const subscription = fromEvent<MediaQueryListEvent>(mediaQuery, 'change')
            .pipe(map(event => (event.matches ? WindowColorSchemes.dark: WindowColorSchemes.light)))
            .subscribe(value => {
              this.angularZone.run(() => observer.next(value));
            });
          return () => subscription.unsubscribe();
        });
      }),
      // observe ngx-dcf-palettedark class changes
      new Observable<WindowColorScheme>(observer => {
        this.angularZone.runOutsideAngular(() => {
          const observerConfig = { attributes: true, attributeFilter: ['class'] };
          const mutationObserver = new MutationObserver(() => {
            const theme = documentElement.classList.contains(AngularEngineKeys.DARK_PALETTE_CLASS) ?
              WindowColorSchemes.dark: WindowColorSchemes.light;
               this.angularZone.run(() => observer.next(theme));
          });
          mutationObserver.observe(documentElement, observerConfig);
          // this.angularZone.run(() => observer.next(theme));

          // this.angularZone.run(() => observer.next(theme));
          return () => mutationObserver.disconnect();
        });
      })
    ).pipe(
       distinctUntilChanged(),
       tap(scheme => {
        const shouldAdd = scheme === WindowColorSchemes.dark;
        if (shouldAdd) {
          // always add when the emitted scheme is dark
          this.toggleClass(documentElement, AngularEngineKeys.DARK_PALETTE_CLASS, true);
        } else {
          // remove the class only if the previously stored schema was dark
          if (this.currentSchema === WindowColorSchemes.dark) {
            this.toggleClass(documentElement, AngularEngineKeys.DARK_PALETTE_CLASS, false);
          }
        }
        // store the latest schema value
        this.currentSchema = scheme;
      }),
      takeUntil(this.destroy$),
      shareReplay(1)
    );
  }

  /**
   * @description Observes the scroll state of the active page.
   * @summary
   * This method observes the scroll position of the active page's content and emits a boolean
   * indicating whether the scroll position exceeds the specified offset. It waits for a delay
   * before starting the observation to allow page transitions to complete.
   *
   * @param {number} offset - The scroll offset to compare against.
   * @param {number} [awaitDelay=500] - The delay in milliseconds before starting the observation.
   * @return {Observable<boolean>} An observable that emits `true` if the scroll position exceeds the offset, otherwise `false`.
   * @function observePageScroll
   * @example
   * mediaService.observePageScroll(100).subscribe(isScrolled => {
   *   console.log('Page scrolled past 100px:', isScrolled);
   * });
   */
  observePageScroll(offset: number, awaitDelay: number = 500): Observable<boolean> {
    // await delay for page change to complete
   return timer(awaitDelay)
    .pipe(
      switchMap(
        () => new Observable<boolean>(observer => {
          const activeContent = this._document.querySelector('ion-router-outlet .ion-page:not(.ion-page-hidden) ion-content') as HTMLIonContentElement;
          if (!(activeContent && typeof (activeContent as HTMLIonContentElement).getScrollElement === 'function'))
            return this.angularZone.run(() => observer.next(false));

          (activeContent as HTMLIonContentElement).getScrollElement().then((element: HTMLElement) => {
            const emitScrollState = () => {
              const scrollTop = element.scrollTop || 0;
              this.angularZone.run(() => observer.next(scrollTop > offset));
            };
            element.addEventListener('scroll', emitScrollState);
            emitScrollState();
            return () => element.removeEventListener('scroll', emitScrollState);
          });

        })
      ),
      distinctUntilChanged(),
      takeUntil(this.destroy$),
      shareReplay(1)
    );
  }

  /**
   * @description Loads an SVG file and injects it into a target element.
   * @summary
   * This method fetches an SVG file from the specified path and injects its content
   * into the provided target element. The operation is performed outside Angular's
   * zone to improve performance, and the DOM update is brought back into the Angular
   * zone to ensure change detection.
   *
   * @param {string} path - The path to the SVG file.
   * @param {HTMLElement} target - The target element to inject the SVG content into.
   * @return {void}
   * @function loadSvgObserver
   * @example
   * const targetElement = document.getElementById('svg-container');
   * mediaService.loadSvgObserver('/assets/icons/icon.svg', targetElement);
   */
  loadSvgObserver(http: HttpClient, path: string, target: HTMLElement): void {
    this.angularZone.runOutsideAngular(() => {
      const svg$ = http.get(path, { responseType: 'text' }).pipe(
        takeUntil(this.destroy$),
        shareReplay(1)
      );
      svg$.subscribe(svg => {
        this.angularZone.run(() => {
          target.innerHTML = svg;
        });
      });
    });
  }

  /**
   * @description Determines if the current theme is dark mode.
   * @summary
   * Observes the `colorScheme$` observable and checks if the `DARK_PALETTE_CLASS` is present
   * on the document's root element or if the emitted color scheme is `dark`.
   *
   * @return {Observable<boolean>} An observable that emits `true` if dark mode is active, otherwise `false`.
   * @function isDarkMode
   * @example
   * mediaService.isDarkMode().subscribe(isDark => {
   *   console.log('Dark mode active:', isDark);
   * });
   */
  isDarkMode(): Observable<boolean> {
    const documentElement = this._document.documentElement;
    return this.colorScheme$.pipe(
      map(scheme => documentElement.classList.contains(AngularEngineKeys.DARK_PALETTE_CLASS) || scheme === WindowColorSchemes.dark),
      distinctUntilChanged(),
       shareReplay(1),
      takeUntil(this.destroy$)
    );
  }

  /**
   * @description Toggles dark mode for a specific component.
   * @summary
   * Subscribes to the `colorScheme$` observable and adds or removes the `DARK_PALETTE_CLASS`
   * on the provided component based on the emitted color scheme.
   *
   * @param {ElementRef | HTMLElement} component - The target component to toggle dark mode on.
   * @return {void}
   * @function setDarkMode
   * @example
   * const component = document.querySelector('.my-component');
   * mediaService.setDarkMode(component);
   */
  setDarkMode(component: ElementRef | HTMLElement): void {
    this.colorScheme$.subscribe((scheme) => {
      this.toggleClass(
        component,
        AngularEngineKeys.DARK_PALETTE_CLASS,
        scheme === WindowColorSchemes.dark ? true : false
      );
    });
  }

  /**
   * @description Add or remove a CSS class on one or multiple elements.
   * @summary
   * Accepts an ElementRef, HTMLElement, or array of mixed elements and will add
   * or remove the provided `className` depending on the `add` flag. Operates
   * defensively: resolves ElementRef.nativeElement when needed and ignores
   * falsy entries.
   *
   * @param {(ElementRef | HTMLElement | unknown[])} component - Single element,
   * ElementRef, or array of elements to update.
   * @param {string} className - CSS class name to add or remove.
   * @param {boolean} [add=true] - Whether to add (true) or remove (false) the class.
   * @return {void}
   * @function toggleClass
   * @example
   * // Add a class to a single element
   * mediaService.toggleClass(element, 'active', true);
   *
   * // Remove a class from multiple elements
   * mediaService.toggleClass([element1, element2], 'hidden', false);
   */
  toggleClass(component: ElementRef | HTMLElement | unknown[], className: string, add: boolean = true): void {
    const components = Array.isArray(component) ? component : [component];
    components.forEach(element => {
      if ((element as ElementRef)?.nativeElement)
        element = (element as ElementRef).nativeElement;
      if(element instanceof HTMLElement) {
         switch (add) {
          case true:
            (element as HTMLElement).classList.add(className);
            break;
          case false:
            (element as HTMLElement).classList.remove(className);
            break;
        }
      }
    });
  }

  /**
   * @description Clean up internal subscriptions and observers used by the service.
   * @summary
   * Triggers completion of the internal `destroy$` subject so that any
   * Observables created with `takeUntil(this.destroy$)` will complete and
   * resources are released.
   *
   * @return {void}
   * @function destroy
   */
  destroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}