mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
refactor(web): split settings component into sections
Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
1 parent
d24c67c6e8
commit
c51d8161ea
22 files changed
+1759
-1581
No files matched your search
@@ -2,45 +2,32 @@ import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormArray,
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialog,
|
||||
MatDialogModule,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { QRCodeComponent } from 'angularx-qrcode';
|
||||
import { DialogService } from 'components';
|
||||
import {
|
||||
PlaylistActions,
|
||||
@@ -67,70 +54,57 @@ import {
|
||||
} from 'shared-interfaces';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsService } from './../services/settings.service';
|
||||
import { SettingsAboutSectionComponent } from './settings-about-section.component';
|
||||
import { SettingsBackupSectionComponent } from './settings-backup-section.component';
|
||||
import {
|
||||
SettingsDeleteAllPlaylistsDialogComponent,
|
||||
SettingsDeleteAllPlaylistsDialogData,
|
||||
} from './settings-delete-all-playlists-dialog.component';
|
||||
|
||||
interface SettingsSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface ObservedSettingsSection {
|
||||
id: string;
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
interface ThemeOption {
|
||||
value: Theme;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
interface StartupBehaviorOption {
|
||||
value: StartupBehavior;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
interface CoverSizeOption {
|
||||
value: CoverSize;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
type SettingsPlaylistDeleteSummary =
|
||||
SettingsDeleteAllPlaylistsDialogData['summary'];
|
||||
import { SettingsEpgSectionComponent } from './settings-epg-section.component';
|
||||
import { createEpgUrlControl } from './settings-form.utils';
|
||||
import { SettingsGeneralSectionComponent } from './settings-general-section.component';
|
||||
import {
|
||||
SettingsPlaylistDeleteSummary,
|
||||
SettingsSection,
|
||||
} from './settings.models';
|
||||
import {
|
||||
buildSettingsSectionNavItems,
|
||||
SETTINGS_COVER_SIZE_OPTIONS,
|
||||
SETTINGS_EMBEDDED_PLAYER_OPTIONS,
|
||||
SETTINGS_OS_PLAYER_OPTIONS,
|
||||
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
|
||||
SETTINGS_THEME_OPTIONS,
|
||||
} from './settings-options';
|
||||
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
|
||||
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
|
||||
import { SettingsResetSectionComponent } from './settings-reset-section.component';
|
||||
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
|
||||
|
||||
@Component({
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrls: ['./settings.component.scss'],
|
||||
host: {
|
||||
class: 'settings-page-host',
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
imports: [
|
||||
CommonModule,
|
||||
EpgSourceStatusComponent,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatCheckboxModule,
|
||||
MatDividerModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
MatTooltipModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
MatDialogModule,
|
||||
QRCodeComponent,
|
||||
SettingsAboutSectionComponent,
|
||||
SettingsBackupSectionComponent,
|
||||
SettingsEpgSectionComponent,
|
||||
SettingsGeneralSectionComponent,
|
||||
SettingsPlaybackSectionComponent,
|
||||
SettingsRemoteControlSectionComponent,
|
||||
SettingsResetSectionComponent,
|
||||
SettingsSectionScrollDirective,
|
||||
],
|
||||
})
|
||||
export class SettingsComponent implements OnInit, OnDestroy {
|
||||
private static readonly SECTION_SCROLL_TOP_GUTTER = 112;
|
||||
private static readonly SECTION_SCROLL_BOTTOM_GUTTER = 124;
|
||||
private static readonly PENDING_SCROLL_CLEAR_DELAY_MS = 600;
|
||||
|
||||
private dialogService = inject(DialogService);
|
||||
public dataService = inject(DataService);
|
||||
private epgService = inject(EpgService);
|
||||
@@ -143,8 +117,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
private translate = inject(TranslateService);
|
||||
private matDialog = inject(MatDialog);
|
||||
private playlistBackupService = inject(PlaylistBackupService);
|
||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly dialogData = inject<{ isDialog: boolean } | null>(
|
||||
MAT_DIALOG_DATA,
|
||||
@@ -179,30 +151,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
]
|
||||
: []), */
|
||||
{
|
||||
id: VideoPlayer.MPV,
|
||||
labelKey: 'SETTINGS.PLAYER_MPV',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.VLC,
|
||||
labelKey: 'SETTINGS.PLAYER_VLC',
|
||||
},
|
||||
...SETTINGS_OS_PLAYER_OPTIONS,
|
||||
]);
|
||||
|
||||
/** Player options */
|
||||
readonly players = computed(() => [
|
||||
{
|
||||
id: VideoPlayer.Html5Player,
|
||||
labelKey: 'SETTINGS.PLAYER_HTML5',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.VideoJs,
|
||||
labelKey: 'SETTINGS.PLAYER_VIDEOJS',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.ArtPlayer,
|
||||
labelKey: 'SETTINGS.PLAYER_ARTPLAYER',
|
||||
},
|
||||
...SETTINGS_EMBEDDED_PLAYER_OPTIONS,
|
||||
...(this.isDesktop ? this.osPlayers() : []),
|
||||
]);
|
||||
|
||||
@@ -216,52 +170,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
epgAvailable$ = this.store.select(selectIsEpgAvailable);
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
|
||||
readonly themeOptions: ThemeOption[] = [
|
||||
{
|
||||
value: Theme.LightTheme,
|
||||
icon: 'light_mode',
|
||||
labelKey: 'THEMES.LIGHT_THEME',
|
||||
},
|
||||
{
|
||||
value: Theme.DarkTheme,
|
||||
icon: 'dark_mode',
|
||||
labelKey: 'THEMES.DARK_THEME',
|
||||
},
|
||||
{
|
||||
value: Theme.SystemTheme,
|
||||
icon: 'desktop_windows',
|
||||
labelKey: 'THEMES.SYSTEM_THEME',
|
||||
},
|
||||
];
|
||||
|
||||
readonly coverSizeOptions: CoverSizeOption[] = [
|
||||
{
|
||||
value: 'small',
|
||||
icon: 'view_module',
|
||||
labelKey: 'SETTINGS.COVER_SIZE_SMALL',
|
||||
},
|
||||
{
|
||||
value: 'medium',
|
||||
icon: 'view_comfy',
|
||||
labelKey: 'SETTINGS.COVER_SIZE_MEDIUM',
|
||||
},
|
||||
{
|
||||
value: 'large',
|
||||
icon: 'view_quilt',
|
||||
labelKey: 'SETTINGS.COVER_SIZE_LARGE',
|
||||
},
|
||||
];
|
||||
|
||||
readonly startupBehaviorOptions: StartupBehaviorOption[] = [
|
||||
{
|
||||
value: StartupBehavior.FirstView,
|
||||
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_FIRST_VIEW',
|
||||
},
|
||||
{
|
||||
value: StartupBehavior.RestoreLastView,
|
||||
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_RESTORE_LAST_VIEW',
|
||||
},
|
||||
];
|
||||
readonly themeOptions = SETTINGS_THEME_OPTIONS;
|
||||
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
|
||||
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
|
||||
|
||||
/** Settings form object */
|
||||
settingsForm = this.formBuilder.group({
|
||||
@@ -304,57 +215,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
readonly removeAllProgress = signal<DbOperationEvent | null>(null);
|
||||
|
||||
private settingsStore = inject(SettingsStore);
|
||||
private sectionObserver?: IntersectionObserver;
|
||||
private pendingScrollClearTimer: ReturnType<
|
||||
typeof window.setTimeout
|
||||
> | null = null;
|
||||
private pendingScrollClearRoot: HTMLElement | null = null;
|
||||
private pendingScrollEndListener: (() => void) | null = null;
|
||||
|
||||
readonly sectionNavItems: SettingsSection[] = [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'SETTINGS.NAV_GENERAL',
|
||||
icon: 'tune',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'playback',
|
||||
label: 'SETTINGS.NAV_PLAYBACK',
|
||||
icon: 'play_circle',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'epg',
|
||||
label: 'SETTINGS.NAV_EPG',
|
||||
icon: 'calendar_month',
|
||||
visible: this.isDesktop,
|
||||
},
|
||||
{
|
||||
id: 'remote-control',
|
||||
label: 'SETTINGS.NAV_REMOTE',
|
||||
icon: 'smartphone',
|
||||
visible: this.isDesktop,
|
||||
},
|
||||
{
|
||||
id: 'backup',
|
||||
label: 'SETTINGS.NAV_BACKUP',
|
||||
icon: 'backup',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'reset',
|
||||
label: 'SETTINGS.NAV_RESET',
|
||||
icon: 'delete_sweep',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: 'SETTINGS.NAV_ABOUT',
|
||||
icon: 'info',
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems(
|
||||
this.isDesktop
|
||||
);
|
||||
|
||||
readonly playlistDeleteSummary = computed<SettingsPlaylistDeleteSummary>(
|
||||
() => {
|
||||
@@ -400,59 +263,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
return this.translate.instant('SETTINGS.REMOVE_ALL_IN_PROGRESS');
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
const sectionId = this.settingsCtx.pendingScrollTarget();
|
||||
if (!sectionId || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRoot = this.scrollToSection(sectionId);
|
||||
this.schedulePendingScrollTargetClear(scrollRoot);
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
|
||||
effect(
|
||||
(onCleanup) => {
|
||||
const activeSectionId = this.activeSection();
|
||||
const activeSectionElement =
|
||||
this.elementRef.nativeElement.querySelector(
|
||||
`#${activeSectionId}`
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!activeSectionElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animation = activeSectionElement.animate(
|
||||
[
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 12px 22px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: 260,
|
||||
easing: 'ease-out',
|
||||
}
|
||||
);
|
||||
|
||||
onCleanup(() => animation.cancel());
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
}
|
||||
|
||||
get sectionNav(): SettingsSection[] {
|
||||
return this.sectionNavItems.filter((section) => section.visible);
|
||||
}
|
||||
@@ -472,8 +282,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
if (!this.isDialog) {
|
||||
this.settingsCtx.setSections(this.sectionNav);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.setupSectionObserver());
|
||||
}
|
||||
|
||||
/* private async loadEmbeddedMpvSupport(): Promise<void> {
|
||||
@@ -512,8 +320,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
} */
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.sectionObserver?.disconnect();
|
||||
this.settingsCtx.reset();
|
||||
}
|
||||
|
||||
@@ -578,17 +384,13 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
* @param epgUrls urls of the EPG sources
|
||||
*/
|
||||
setEpgUrls(epgUrls: string[] | string): void {
|
||||
const URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
|
||||
|
||||
const urls = Array.isArray(epgUrls) ? epgUrls : [epgUrls];
|
||||
const filteredUrls = urls
|
||||
.map((url) => url.trim())
|
||||
.filter((url) => url !== '');
|
||||
|
||||
filteredUrls.forEach((url) => {
|
||||
this.epgUrl.push(
|
||||
new FormControl(url, [Validators.pattern(URL_REGEX)])
|
||||
);
|
||||
this.epgUrl.push(createEpgUrlControl(url));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -734,14 +536,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
* Initializes new entry in form array for EPG URL
|
||||
*/
|
||||
addEpgSource(): void {
|
||||
this.epgUrl.insert(
|
||||
this.epgUrl.length,
|
||||
new FormControl('', {
|
||||
validators: [
|
||||
Validators.pattern(/^(http|https|file):\/\/[^ "]+$/),
|
||||
],
|
||||
})
|
||||
);
|
||||
this.epgUrl.insert(this.epgUrl.length, createEpgUrlControl());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -992,179 +787,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.removeAllProgress.set(event);
|
||||
}
|
||||
|
||||
private setupSectionObserver(): void {
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
const contentSections = Array.from(
|
||||
this.elementRef.nativeElement.querySelectorAll(
|
||||
'.settings-group[id]'
|
||||
)
|
||||
) as HTMLElement[];
|
||||
const sections: ObservedSettingsSection[] = contentSections.map(
|
||||
(section) => ({
|
||||
id: section.id,
|
||||
element: section,
|
||||
})
|
||||
);
|
||||
|
||||
if (sections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sectionObserver?.disconnect();
|
||||
this.sectionObserver = new IntersectionObserver(
|
||||
() => {
|
||||
if (this.settingsCtx.pendingScrollTarget()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSection = this.resolveActiveSection(sections);
|
||||
if (activeSection) {
|
||||
this.settingsCtx.setActiveSection(activeSection);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollRoot,
|
||||
threshold: [0.12, 0.24, 0.4, 0.6],
|
||||
rootMargin: '-18% 0px -52% 0px',
|
||||
}
|
||||
);
|
||||
|
||||
sections.forEach((section) =>
|
||||
this.sectionObserver?.observe(section.element)
|
||||
);
|
||||
|
||||
const initialSection = this.resolveActiveSection(sections);
|
||||
if (initialSection) {
|
||||
this.settingsCtx.setActiveSection(initialSection);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveActiveSection(
|
||||
sections: ObservedSettingsSection[]
|
||||
): string | null {
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
const rootTop = scrollRoot?.getBoundingClientRect().top ?? 0;
|
||||
const rootHeight = scrollRoot?.clientHeight ?? window.innerHeight;
|
||||
const activationLine = rootTop + Math.min(rootHeight * 0.28, 220);
|
||||
const sectionAtActivationLine = sections.find((section) => {
|
||||
const rect = section.element.getBoundingClientRect();
|
||||
return rect.top <= activationLine && rect.bottom >= activationLine;
|
||||
});
|
||||
|
||||
if (sectionAtActivationLine) {
|
||||
return sectionAtActivationLine.id;
|
||||
}
|
||||
|
||||
const nearestSection = sections
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
distance: Math.abs(
|
||||
section.element.getBoundingClientRect().top - activationLine
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0];
|
||||
|
||||
return nearestSection?.id ?? null;
|
||||
}
|
||||
|
||||
private getScrollRoot(): HTMLElement | null {
|
||||
return this.elementRef.nativeElement.closest(
|
||||
'main.workspace-content'
|
||||
) as HTMLElement | null;
|
||||
}
|
||||
|
||||
private schedulePendingScrollTargetClear(
|
||||
scrollRoot: HTMLElement | null
|
||||
): void {
|
||||
const clearPendingScrollTarget = () => {
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.settingsCtx.clearPendingScrollTarget();
|
||||
};
|
||||
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.pendingScrollClearTimer = window.setTimeout(
|
||||
clearPendingScrollTarget,
|
||||
SettingsComponent.PENDING_SCROLL_CLEAR_DELAY_MS
|
||||
);
|
||||
this.pendingScrollClearRoot = scrollRoot;
|
||||
this.pendingScrollEndListener = clearPendingScrollTarget;
|
||||
scrollRoot?.addEventListener?.('scrollend', clearPendingScrollTarget, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
private cancelPendingScrollTargetClear(): void {
|
||||
if (this.pendingScrollClearTimer) {
|
||||
clearTimeout(this.pendingScrollClearTimer);
|
||||
this.pendingScrollClearTimer = null;
|
||||
}
|
||||
|
||||
if (this.pendingScrollClearRoot && this.pendingScrollEndListener) {
|
||||
this.pendingScrollClearRoot.removeEventListener?.(
|
||||
'scrollend',
|
||||
this.pendingScrollEndListener
|
||||
);
|
||||
}
|
||||
|
||||
this.pendingScrollClearRoot = null;
|
||||
this.pendingScrollEndListener = null;
|
||||
}
|
||||
|
||||
private scrollToSection(sectionId: string): HTMLElement | null {
|
||||
const sectionElement = document.getElementById(sectionId);
|
||||
if (!sectionElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
if (!scrollRoot) {
|
||||
sectionElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootRect = scrollRoot.getBoundingClientRect();
|
||||
const sectionRect = sectionElement.getBoundingClientRect();
|
||||
const sectionTop =
|
||||
scrollRoot.scrollTop + (sectionRect.top - rootRect.top);
|
||||
const sectionBottom = sectionTop + sectionRect.height;
|
||||
const visibleTop =
|
||||
scrollRoot.scrollTop + SettingsComponent.SECTION_SCROLL_TOP_GUTTER;
|
||||
const visibleBottom =
|
||||
scrollRoot.scrollTop +
|
||||
scrollRoot.clientHeight -
|
||||
SettingsComponent.SECTION_SCROLL_BOTTOM_GUTTER;
|
||||
let nextScrollTop = scrollRoot.scrollTop;
|
||||
|
||||
if (sectionTop < visibleTop) {
|
||||
nextScrollTop =
|
||||
sectionTop - SettingsComponent.SECTION_SCROLL_TOP_GUTTER;
|
||||
} else if (sectionBottom > visibleBottom) {
|
||||
nextScrollTop =
|
||||
sectionBottom -
|
||||
scrollRoot.clientHeight +
|
||||
SettingsComponent.SECTION_SCROLL_BOTTOM_GUTTER;
|
||||
}
|
||||
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
scrollRoot.scrollHeight - scrollRoot.clientHeight
|
||||
);
|
||||
|
||||
scrollRoot.scrollTo({
|
||||
top: Math.min(Math.max(nextScrollTop, 0), maxScrollTop),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
return scrollRoot;
|
||||
}
|
||||
|
||||
private openSettingsSnackbar(
|
||||
message: string,
|
||||
config: MatSnackBarConfig = {}
|
||||
|
||||
Reference in new issue
Block a user