diff --git a/package-lock.json b/package-lock.json
index dfa36e1a..3215dfeb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -59,6 +59,7 @@
"jasmine-spec-reporter": "~7.0.0",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",
+ "karma-coverage": "^2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"ng-packagr": "^18.2.1",
@@ -13643,6 +13644,12 @@
}
]
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true
+ },
"node_modules/html-minifier-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
@@ -14658,6 +14665,92 @@
"node": ">=10"
}
},
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-report/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report/node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/istanbul-lib-report/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
+ "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.1.1",
+ "istanbul-lib-coverage": "^3.0.0",
+ "source-map": "^0.6.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps/node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
+ "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
+ "dev": true,
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -14986,6 +15079,70 @@
"which": "^1.2.1"
}
},
+ "node_modules/karma-coverage": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz",
+ "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==",
+ "dev": true,
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.2.0",
+ "istanbul-lib-instrument": "^5.1.0",
+ "istanbul-lib-report": "^3.0.0",
+ "istanbul-lib-source-maps": "^4.0.1",
+ "istanbul-reports": "^3.0.5",
+ "minimatch": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/karma-coverage/node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz",
+ "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.12.3",
+ "@babel/parser": "^7.14.7",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-coverage": "^3.2.0",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/karma-coverage/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/karma-coverage/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/karma-jasmine": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz",
diff --git a/package.json b/package.json
index 20b412a6..a1a2fb9a 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,8 @@
"start-demo": "ng serve ng-gallery-demo --host 0.0.0.0",
"build-demo": "ng build ng-gallery-demo --configuration production",
"build-lib": "ng build ng-gallery --configuration production",
+ "test-lib": "ng test ng-gallery",
+ "test-lib-headless": "ng test ng-gallery --watch=false --no-progress --browsers=ChromeHeadless --code-coverage",
"publish-lib": "npm publish ./dist/ng-gallery",
"storybook": "ng run ng-gallery:storybook",
"build-storybook": "ng run ng-gallery:build-storybook",
@@ -68,6 +70,7 @@
"jasmine-spec-reporter": "~7.0.0",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",
+ "karma-coverage": "^2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"ng-packagr": "^18.2.1",
diff --git a/projects/ng-gallery-demo/src/app/pages/lab/lab.component.html b/projects/ng-gallery-demo/src/app/pages/lab/lab.component.html
index b95e5fa9..ba9b609b 100644
--- a/projects/ng-gallery-demo/src/app/pages/lab/lab.component.html
+++ b/projects/ng-gallery-demo/src/app/pages/lab/lab.component.html
@@ -8,6 +8,7 @@
Show thumbnails
+
+ Centralize Slider
+
Centralize Thumbnails
diff --git a/projects/ng-gallery-demo/src/app/pages/lab/lab.component.ts b/projects/ng-gallery-demo/src/app/pages/lab/lab.component.ts
index 9855b088..e87a8bd2 100644
--- a/projects/ng-gallery-demo/src/app/pages/lab/lab.component.ts
+++ b/projects/ng-gallery-demo/src/app/pages/lab/lab.component.ts
@@ -74,6 +74,7 @@ export class LabComponent implements OnInit {
disableThumbScroll: false,
disableMouseScroll: false,
disableThumbMouseScroll: false,
+ centralized: false,
thumbWidth: 120,
thumbHeight: 90,
imageSize: 'contain',
diff --git a/projects/ng-gallery/karma.conf.js b/projects/ng-gallery/karma.conf.js
index f2d7fdd8..8cbeba3e 100644
--- a/projects/ng-gallery/karma.conf.js
+++ b/projects/ng-gallery/karma.conf.js
@@ -9,24 +9,39 @@ module.exports = function (config) {
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
- require('karma-coverage-istanbul-reporter'),
+ require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
+ jasmine: {
+ // you can add configuration options for Jasmine here
+ // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
+ // for example, you can disable the random execution with `random: false`
+ // or set a specific seed with `seed: 4321`
+ },
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
- coverageIstanbulReporter: {
+ jasmineHtmlReporter: {
+ suppressAll: true // removes the duplicated traces
+ },
+ coverageReporter: {
dir: require('path').join(__dirname, '../../coverage/ng-gallery'),
- reports: ['html', 'lcovonly', 'text-summary'],
- fixWebpackSourcePaths: true
+ subdir: '.',
+ reporters: [
+ { type: 'html' },
+ { type: 'text-summary' },
+ { type: 'cobertura' },
+ { type: 'lcov' }
+ ]
},
reporters: ['progress', 'kjhtml'],
- port: 9876,
- colors: true,
- logLevel: config.LOG_INFO,
- autoWatch: true,
- browsers: ['Chrome'],
- singleRun: false,
+ browsers: ["MyChromeWithoutSearchSelect"],
+ customLaunchers: {
+ MyChromeWithoutSearchSelect: {
+ base: "Chrome",
+ flags: ["-disable-search-engine-choice-screen"],
+ },
+ },
restartOnFileChange: true
});
};
diff --git a/projects/ng-gallery/src/lib/components/adapters/base-adapter.ts b/projects/ng-gallery/src/lib/components/adapters/base-adapter.ts
index 0fc529d6..5e2570b2 100644
--- a/projects/ng-gallery/src/lib/components/adapters/base-adapter.ts
+++ b/projects/ng-gallery/src/lib/components/adapters/base-adapter.ts
@@ -10,7 +10,7 @@ export abstract class SliderAdapter {
abstract get isContentLessThanContainer(): boolean;
- abstract getScrollToValue(el: Element, behavior: ScrollBehavior): ScrollToOptions;
+ abstract getScrollToValue(target: Element, behavior: ScrollBehavior): ScrollToOptions;
abstract getCentralizerStartSize(): number;
diff --git a/projects/ng-gallery/src/lib/components/adapters/main-adapters.ts b/projects/ng-gallery/src/lib/components/adapters/main-adapters.ts
index df715644..26cb9c1f 100644
--- a/projects/ng-gallery/src/lib/components/adapters/main-adapters.ts
+++ b/projects/ng-gallery/src/lib/components/adapters/main-adapters.ts
@@ -31,8 +31,8 @@ export class HorizontalAdapter implements SliderAdapter {
constructor(public slider: HTMLElement, public config: GalleryConfig) {
}
- getScrollToValue(el: HTMLElement, behavior: ScrollBehavior): SmoothScrollOptions {
- const position: number = el.offsetLeft - ((this.clientSize - el.clientWidth) / 2);
+ getScrollToValue(target: HTMLElement, behavior: ScrollBehavior): SmoothScrollOptions {
+ const position: number = target.offsetLeft - ((this.clientSize - target.clientWidth) / 2);
return {
behavior,
start: position
@@ -40,7 +40,8 @@ export class HorizontalAdapter implements SliderAdapter {
}
getRootMargin(): string {
- return `1000px 1px 1000px 1px`;
+ // return `1000px 1px 1000px 1px`;
+ return `1000px 0px 1000px 0px`;
}
getElementRootMargin(viewport: HTMLElement, el: HTMLElement): string {
@@ -108,8 +109,8 @@ export class VerticalAdapter implements SliderAdapter {
constructor(public slider: HTMLElement, public config: GalleryConfig) {
}
- getScrollToValue(el: HTMLElement, behavior: ScrollBehavior): SmoothScrollOptions {
- const position: number = el.offsetTop - ((this.clientSize - el.clientHeight) / 2);
+ getScrollToValue(target: HTMLElement, behavior: ScrollBehavior): SmoothScrollOptions {
+ const position: number = target.offsetTop - ((this.clientSize - target.clientHeight) / 2);
return {
behavior,
top: position
@@ -117,7 +118,7 @@ export class VerticalAdapter implements SliderAdapter {
}
getRootMargin(): string {
- return `1px 1000px 1px 1000px`;
+ return `0px 1000px 0px 1000px`;
}
getElementRootMargin(viewport: HTMLElement, el: HTMLElement): string {
diff --git a/projects/ng-gallery/src/lib/components/gallery-item.component.ts b/projects/ng-gallery/src/lib/components/gallery-item.component.ts
index 931db745..d91975fd 100644
--- a/projects/ng-gallery/src/lib/components/gallery-item.component.ts
+++ b/projects/ng-gallery/src/lib/components/gallery-item.component.ts
@@ -139,7 +139,6 @@ export class GalleryItemComponent implements AfterViewInit {
imageContext: Signal> = computed(() => {
- console.log('imageContext')
return {
$implicit: this.imageData,
index: this.index(),
@@ -152,7 +151,6 @@ export class GalleryItemComponent implements AfterViewInit {
});
itemContext: Signal> = computed(() => {
- console.log('itemContext')
return {
$implicit: this.data(),
index: this.index(),
diff --git a/projects/ng-gallery/src/lib/components/gallery-nav.component.ts b/projects/ng-gallery/src/lib/components/gallery-nav.component.ts
index 3f5f4f23..3b3d1073 100644
--- a/projects/ng-gallery/src/lib/components/gallery-nav.component.ts
+++ b/projects/ng-gallery/src/lib/components/gallery-nav.component.ts
@@ -26,9 +26,9 @@ import { GalleryRef } from '../services/gallery-ref';
})
export class GalleryNavComponent {
- private _sanitizer: DomSanitizer = inject(DomSanitizer);
+ private readonly _sanitizer: DomSanitizer = inject(DomSanitizer);
- galleryRef: GalleryRef = inject(GalleryRef);
+ readonly galleryRef: GalleryRef = inject(GalleryRef);
navIcon: Signal = computed(() =>
this._sanitizer.bypassSecurityTrustHtml(this.galleryRef.config().navIcon)
diff --git a/projects/ng-gallery/src/lib/components/gallery-slider.component.ts b/projects/ng-gallery/src/lib/components/gallery-slider.component.ts
index 56360db4..aac69431 100644
--- a/projects/ng-gallery/src/lib/components/gallery-slider.component.ts
+++ b/projects/ng-gallery/src/lib/components/gallery-slider.component.ts
@@ -1,66 +1,33 @@
-import {
- Component,
- inject,
- signal,
- computed,
- output,
- effect,
- untracked,
- viewChildren,
- input,
- viewChild,
- Signal,
- ElementRef,
- InputSignal,
- WritableSignal,
- OutputEmitterRef,
- ChangeDetectionStrategy
-} from '@angular/core';
+import { Component, inject, output, OutputEmitterRef, ChangeDetectionStrategy } from '@angular/core';
import { GalleryError } from '../models/gallery.model';
-import { GalleryConfig } from '../models/config.model';
-import { Orientation } from '../models/constants';
-import { SliderAdapter, HorizontalAdapter, VerticalAdapter } from './adapters';
-import { SmoothScroll, SmoothScrollOptions } from '../smooth-scroll';
+import { GalleryRef } from '../services/gallery-ref';
+import { SmoothScroll } from '../smooth-scroll';
import { HammerSliding } from '../gestures/hammer-sliding.directive';
-import { SliderIntersectionObserver } from '../observers/slider-intersection-observer.directive';
-import { ItemIntersectionObserver } from '../observers/item-intersection-observer.directive';
+import { IntersectionSensor } from '../observers/intersection-sensor.directive';
import { GalleryItemComponent } from './gallery-item.component';
-import { SliderResizeObserver } from '../observers/slider-resize-observer.directive';
-import { GalleryRef } from '../services/gallery-ref';
import { ScrollSnapType } from '../services/scroll-snap-type';
+import { ResizeSensor } from '../services/resize-sensor';
+import { SliderComponent } from './slider/slider';
@Component({
standalone: true,
selector: 'gallery-slider',
template: `
-
+
@for (item of galleryRef.items(); track item.data.src; let i = $index; let count = $count) {
-
}
@@ -71,81 +38,31 @@ import { ScrollSnapType } from '../services/scroll-snap-type';
RESIZING
SCROLLING
SLIDING
+
CURRENT: {{ galleryRef.currIndex() }}
}
-
+
`,
styleUrl: './gallery-slider.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [GalleryItemComponent, SmoothScroll, HammerSliding, SliderIntersectionObserver, ItemIntersectionObserver, SliderResizeObserver, ScrollSnapType]
+ imports: [
+ ResizeSensor,
+ IntersectionSensor,
+ SmoothScroll,
+ HammerSliding,
+ ScrollSnapType,
+ GalleryItemComponent,
+ SliderComponent
+ ]
})
export class GallerySliderComponent {
readonly galleryRef: GalleryRef = inject(GalleryRef);
- /** Stream that emits the slider position */
- position: WritableSignal = signal(null);
-
- isScrolling: WritableSignal = signal(false);
- isSliding: WritableSignal = signal(false);
- isResizing: WritableSignal = signal(false);
-
- disableInteractionObserver: Signal = computed(() => {
- return this.isScrolling() || this.isSliding() || this.isResizing();
- });
-
- /** Gallery ID */
- galleryId: InputSignal = input();
-
- /** Slider ElementRef */
- sliderRef: Signal> = viewChild('slider');
-
- slider: Signal = computed(() => this.sliderRef().nativeElement);
-
- items: Signal> = viewChildren(GalleryItemComponent);
-
- /** Slider adapter */
- adapter: Signal = computed(() => {
- const config: GalleryConfig = this.galleryRef.config();
- return config.orientation === Orientation.Horizontal ?
- new HorizontalAdapter(this.slider(), config) :
- new VerticalAdapter(this.slider(), config);
- });
-
/** Stream that emits when thumb is clicked */
itemClick: OutputEmitterRef = output();
/** Stream that emits when an error occurs */
error: OutputEmitterRef = output();
-
- constructor() {
- effect(() => {
- const currIndex: number = this.galleryRef.currIndex();
- const behavior: ScrollBehavior = this.galleryRef.scrollBehavior()
- if (behavior) {
- // Scroll to index when current index changes
- untracked(() => {
- this.scrollToIndex(currIndex, behavior);
- });
- }
- });
- }
-
- onActiveIndexChange(index: number): void {
- if (index === -1) {
- // Reset active index position
- this.scrollToIndex(this.galleryRef.currIndex(), 'smooth');
- } else {
- this.galleryRef.set(index, 'smooth');
- }
- }
-
- private scrollToIndex(index: number, behavior: ScrollBehavior): void {
- const el: HTMLElement = this.items()[index]?.nativeElement;
- if (el) {
- const pos: SmoothScrollOptions = this.adapter().getScrollToValue(el, behavior || this.galleryRef.config().scrollBehavior);
- this.position.set(pos);
- }
- }
}
diff --git a/projects/ng-gallery/src/lib/components/gallery-slider.scss b/projects/ng-gallery/src/lib/components/gallery-slider.scss
index 02adaddb..eed03735 100644
--- a/projects/ng-gallery/src/lib/components/gallery-slider.scss
+++ b/projects/ng-gallery/src/lib/components/gallery-slider.scss
@@ -21,14 +21,46 @@
overflow: var(--slider-overflow);
scroll-snap-type: var(--slider-scroll-snap-type);
+ scroll-snap-stop: always;
flex-direction: var(--slider-flex-direction);
scrollbar-width: none;
+ // Gallery items variables
+ --g-item-width: unset;
+ --g-item-height: unset;
+ --g-item-max-height: var(--slider-height);
+
+ &[orientation='horizontal'] {
+ --g-item-width: var(--slider-width);
+ --g-item-height: 100%;
+
+ &[autoSize='true'] {
+ --g-item-width: auto;
+ }
+ }
+
+ &[orientation='vertical'] {
+ --g-item-width: 100%;
+ --g-item-height: var(--slider-height);
+
+ &[autoSize='true'] {
+ --g-item-height: auto;
+ }
+ }
+
&::-webkit-scrollbar {
display: none;
}
+ &.g-resizing {
+ ::ng-deep {
+ gallery-item {
+ visibility: hidden;
+ }
+ }
+ }
+
&.g-sliding, &.g-scrolling {
// Disable mouse click on gallery items/thumbnails when the slider is being dragged using the mouse
.g-slider-content {
@@ -42,11 +74,11 @@
}
&:before {
- flex: 0 0 var(--slider-centralize-start-size);
+ flex: 0 0 var(--centralize-start-size);
}
&:after {
- flex: 0 0 var(--slider-centralize-end-size);
+ flex: 0 0 var(--centralize-end-size);
}
}
}
@@ -55,7 +87,7 @@
flex: 0 0 auto;
display: flex;
align-items: center;
- gap: 1px;
+ //gap: 1px;
width: var(--slider-content-width, unset);
height: var(--slider-content-height, unset);
flex-direction: var(--slider-flex-direction);
diff --git a/projects/ng-gallery/src/lib/components/gallery-thumb.component.ts b/projects/ng-gallery/src/lib/components/gallery-thumb.component.ts
index 00b077b4..993275c6 100644
--- a/projects/ng-gallery/src/lib/components/gallery-thumb.component.ts
+++ b/projects/ng-gallery/src/lib/components/gallery-thumb.component.ts
@@ -23,6 +23,7 @@ import { GalleryRef } from '../services/gallery-ref';
host: {
'[attr.galleryIndex]': 'index()',
'[class.g-active-thumb]': 'isActive()',
+ '[class.g-visible-thumb]': 'visible()',
},
template: `
= input();
/** Item's data, this object contains the data required to display the content (e.g. src path) */
- data: InputSignal = input()
+ data: InputSignal = input();
+
+ /** Whether the item is visible in the viewport */
+ visible: InputSignal = input();
isActive: Signal = computed(() => this.index() === this.currIndex());
diff --git a/projects/ng-gallery/src/lib/components/gallery-thumb.scss b/projects/ng-gallery/src/lib/components/gallery-thumb.scss
index 2fb702bf..21f43e52 100644
--- a/projects/ng-gallery/src/lib/components/gallery-thumb.scss
+++ b/projects/ng-gallery/src/lib/components/gallery-thumb.scss
@@ -22,6 +22,11 @@
&.g-active-thumb {
--g-thumb-opacity: 1;
}
+
+ &.g-visible-thumb {
+ border: 1px solid red;
+ --g-thumb-opacity: 1;
+ }
}
.g-template {
diff --git a/projects/ng-gallery/src/lib/components/gallery-thumbs.component.ts b/projects/ng-gallery/src/lib/components/gallery-thumbs.component.ts
index ba8e9b80..76242813 100644
--- a/projects/ng-gallery/src/lib/components/gallery-thumbs.component.ts
+++ b/projects/ng-gallery/src/lib/components/gallery-thumbs.component.ts
@@ -4,18 +4,15 @@ import {
signal,
computed,
output,
- effect,
- untracked,
viewChildren,
- input,
viewChild,
Signal,
ElementRef,
- InputSignal,
WritableSignal,
OutputEmitterRef,
ChangeDetectionStrategy
} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { GalleryConfig } from '../models/config.model';
import { GalleryError } from '../models/gallery.model';
import { ThumbnailsPosition } from '../models/constants';
@@ -23,11 +20,10 @@ import { VerticalAdapter, HorizontalAdapter, SliderAdapter } from './adapters';
import { SmoothScroll, SmoothScrollOptions } from '../smooth-scroll';
import { GalleryThumbComponent } from './gallery-thumb.component';
import { HammerSliding } from '../gestures/hammer-sliding.directive';
-import { ThumbResizeObserver } from '../observers/thumb-resize-observer.directive';
import { GalleryRef } from '../services/gallery-ref';
import { ResizeSensor } from '../services/resize-sensor';
-import { SliderCentraliser } from '../services/slider-centraliser';
import { ScrollSnapType } from '../services/scroll-snap-type';
+import { IndexChange } from '../models/slider.model';
@Component({
standalone: true,
@@ -35,20 +31,14 @@ import { ScrollSnapType } from '../services/scroll-snap-type';
template: `
+ hammerSliding>
@for (item of galleryRef.items(); track item.data.src; let i = $index; let count = $count) {
-
= input();
-
/** Slider ElementRef */
sliderRef: Signal> = viewChild('slider');
@@ -107,27 +92,11 @@ export class GalleryThumbsComponent {
error: OutputEmitterRef = output();
constructor() {
- effect(() => {
- const currIndex: number = this.galleryRef.currIndex();
- const behavior: ScrollBehavior = this.galleryRef.scrollBehavior()
- if (behavior) {
- // Scroll to index when current index changes
- untracked(() => {
- this.scrollToIndex(currIndex, behavior);
- });
- }
+ this.galleryRef.indexChange.pipe(takeUntilDestroyed()).subscribe((change: IndexChange) => {
+ this.scrollToIndex(change.index, change.behavior);
});
}
- onActiveIndexChange(index: number): void {
- if (index === -1) {
- // Reset active index position
- this.scrollToIndex(this.galleryRef.currIndex(), 'smooth');
- } else {
- this.scrollToIndex(index, 'smooth');
- }
- }
-
scrollToIndex(index: number, behavior: ScrollBehavior): void {
const el: HTMLElement = this.items()[index]?.nativeElement;
if (el) {
diff --git a/projects/ng-gallery/src/lib/components/gallery-thumbs.scss b/projects/ng-gallery/src/lib/components/gallery-thumbs.scss
index 3a21afa0..9c45ceba 100644
--- a/projects/ng-gallery/src/lib/components/gallery-thumbs.scss
+++ b/projects/ng-gallery/src/lib/components/gallery-thumbs.scss
@@ -26,7 +26,6 @@
display: none;
}
- // Disable mouse click on gallery items/thumbnails when the slider is being dragged using the mouse
&.g-sliding {
// Disable mouse click on gallery items/thumbnails when the slider is being dragged using the mouse
.g-slider-content {
diff --git a/projects/ng-gallery/src/lib/components/gallery.component.ts b/projects/ng-gallery/src/lib/components/gallery.component.ts
index 6b8d6a56..78caab32 100644
--- a/projects/ng-gallery/src/lib/components/gallery.component.ts
+++ b/projects/ng-gallery/src/lib/components/gallery.component.ts
@@ -13,7 +13,8 @@ import {
InputSignal,
OutputEmitterRef,
ChangeDetectionStrategy,
- InputSignalWithTransform, TemplateRef,
+ InputSignalWithTransform,
+ TemplateRef,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { Directionality } from '@angular/cdk/bidi';
@@ -60,15 +61,13 @@ import { GalleryThumbsComponent } from './gallery-thumbs.component';
},
selector: 'gallery',
template: `
- @if (thumbs()) {
-
- }
+
+
+
+
@@ -172,6 +171,13 @@ export class GalleryComponent {
transform: booleanAttribute
});
+ /**
+ * Centralize slider
+ */
+ centralized: InputSignalWithTransform = input(this._config.centralized, {
+ transform: booleanAttribute
+ });
+
/**
* De-attaching the thumbnails from the main slider
* If enabled - thumbnails won't automatically scroll to the active thumbnails
@@ -424,6 +430,7 @@ export class GalleryComponent {
autoplay: this.autoplay(),
bulletSize: this.bulletSize(),
imageSize: this.imageSize(),
+ centralized: this.centralized(),
thumbImageSize: this.thumbImageSize(),
scrollBehavior: this.scrollBehavior(),
thumbCentralized: this.thumbCentralized(),
diff --git a/projects/ng-gallery/src/lib/components/gallery.scss b/projects/ng-gallery/src/lib/components/gallery.scss
index 40eb0f50..a3055c4a 100644
--- a/projects/ng-gallery/src/lib/components/gallery.scss
+++ b/projects/ng-gallery/src/lib/components/gallery.scss
@@ -129,29 +129,6 @@
--slider-overflow: hidden !important;
}
- // Gallery items variables
- --g-item-width: unset;
- --g-item-height: unset;
- --g-item-max-height: var(--slider-height);
-
- &[orientation='horizontal'] {
- --g-item-width: var(--slider-width);
- --g-item-height: 100%;
-
- &[itemAutoSize='true'] {
- --g-item-width: auto;
- }
- }
-
- &[orientation='vertical'] {
- --g-item-width: 100%;
- --g-item-height: var(--slider-height);
-
- &[itemAutoSize='true'] {
- --g-item-height: auto;
- }
- }
-
// Gallery bullets variables
--bullets-top: unset;
--bullets-bottom: unset;
diff --git a/projects/ng-gallery/src/lib/components/slider/slider.ts b/projects/ng-gallery/src/lib/components/slider/slider.ts
new file mode 100644
index 00000000..4a785eb8
--- /dev/null
+++ b/projects/ng-gallery/src/lib/components/slider/slider.ts
@@ -0,0 +1,52 @@
+import {
+ Component,
+ inject,
+ computed,
+ contentChildren,
+ input,
+ Signal,
+ InputSignal,
+ ElementRef,
+ ChangeDetectionStrategy
+} from '@angular/core';
+import { GalleryRef } from '../../services/gallery-ref';
+import { GalleryConfig } from '../../models/config.model';
+import { Orientation } from '../../models/constants';
+import { GalleryItemComponent } from '../gallery-item.component';
+import { HorizontalAdapter, SliderAdapter, VerticalAdapter } from '../adapters';
+
+@Component({
+ standalone: true,
+ host: {
+ '[class.g-slider]': 'true',
+ '[attr.centralised]': 'centralized()',
+ '[attr.orientation]': 'orientation()',
+ '[attr.autoSize]': 'autoSize()',
+ },
+ selector: 'g-slider',
+ template: `
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class SliderComponent {
+
+ readonly galleryRef: GalleryRef = inject(GalleryRef);
+
+ readonly nativeElement: HTMLElement = inject(ElementRef).nativeElement;
+
+ readonly orientation: InputSignal = input();
+
+ readonly autoSize: InputSignal = input();
+
+ readonly centralized: InputSignal = input();
+
+ readonly items: Signal> = contentChildren(GalleryItemComponent, { descendants: true });
+
+ readonly adapter: Signal = computed(() => {
+ const config: GalleryConfig = this.galleryRef.config();
+ return this.orientation() === Orientation.Horizontal
+ ? new HorizontalAdapter(this.nativeElement, config)
+ : new VerticalAdapter(this.nativeElement, config);
+ });
+}
diff --git a/projects/ng-gallery/src/lib/components/templates/gallery-image.component.ts b/projects/ng-gallery/src/lib/components/templates/gallery-image.component.ts
index 946a20df..109e535c 100644
--- a/projects/ng-gallery/src/lib/components/templates/gallery-image.component.ts
+++ b/projects/ng-gallery/src/lib/components/templates/gallery-image.component.ts
@@ -43,7 +43,7 @@ import { ItemState } from './items.model';
(error)="state.set('failed'); error.emit($event)"/>
} @else {
= input(false, { alias: 'hammerSliding' });
-
- adapter: InputSignal = input();
-
- galleryId: InputSignal = input();
-
- items: InputSignal = input();
+ private readonly slider: SliderComponent = inject(SliderComponent, { self: true });
sliding: WritableSignal = signal(false);
- @Output() activeIndexChange: EventEmitter = new EventEmitter();
-
- @Output() isSlidingChange: EventEmitter = new EventEmitter();
-
constructor() {
- effect(() => {
- this.enabled() ? this._subscribe(this.adapter()) : this._unsubscribe();
- });
- }
+ if (this._platform.ANDROID || this._platform.IOS || !(this._document.defaultView as any).Hammer) return;
- ngOnDestroy(): void {
- this._unsubscribe();
- }
+ // HammerJS instance
+ let mc: HammerInstance;
- private _subscribe(adapter: SliderAdapter): void {
- this._unsubscribe();
+ effect((onCleanup: EffectCleanupRegisterFn) => {
+ const config: GalleryConfig = this.galleryRef.config();
+ const adapter: SliderAdapter = this.slider.adapter();
- if (!this._platform.ANDROID && !this._platform.IOS && (this._document.defaultView as any).Hammer) {
- this._zone.runOutsideAngular(() => {
+ if (!adapter && !config.disableMouseScroll) return;
- const direction: number = adapter.hammerDirection;
+ untracked(() => {
+ this._zone.runOutsideAngular(() => {
+ const direction: number = adapter.hammerDirection;
- // this.hammer.options = { inputClass: Hammer.MouseInput };
- this.hammer.overrides.pan = { direction };
- this._mc = this.hammer.buildHammer(this._viewport);
+ this.hammer.overrides.pan = { direction };
+ mc = this.hammer.buildHammer(this._viewport);
- // this._hammer = new Hammer(this._viewport, { inputClass: Hammer.MouseInput });
- // this._mc.get('pan').set({ direction });
+ let offset: number;
- let offset: number;
+ // Set panOffset for sliding on pan start event
+ mc.on('panstart', () => {
+ this._zone.run(() => {
+ this.sliding.set(true);
+ });
- // Set panOffset for sliding on pan start event
- this._mc.on('panstart', () => {
- this._zone.run(() => {
- this.isSlidingChange.emit(true);
- this.sliding.set(true);
+ offset = adapter.scrollValue;
});
- offset = adapter.scrollValue;
- // this._viewport.classList.add('g-sliding');
- // this._viewport.style.setProperty('--slider-scroll-snap-type', 'none');
- });
-
- this._mc.on('panmove', (e: any) => {
- this._viewport.scrollTo(adapter.getHammerValue(offset, e, 'auto'));
- });
+ mc.on('panmove', (e: any) => {
+ this._viewport.scrollTo(adapter.getHammerValue(offset, e, 'auto'));
+ });
- this._mc.on('panend', (e: any) => {
- this._document.onselectstart = null;
- // this._viewport.classList.remove('g-sliding');
- const index: number = this.getIndexOnMouseUp(e, adapter);
-
- this._zone.run(() => {
- this.isSlidingChange.emit(false);
- this.activeIndexChange.emit(index);
- console.log(index)
- requestAnimationFrame(() => {
- this.sliding.set(false);
+ mc.on('panend', (e: any) => {
+ this._document.onselectstart = null;
+ // this._viewport.classList.remove('g-sliding');
+
+ const index: number = this.getIndexOnMouseUp(e, this.slider.adapter());
+ if (index !== -1) {
+ this._zone.run(() => {
+ // this.isSlidingChange.emit(false);
+ this.galleryRef.set(index);
+ // Tiny delay is needed to avoid flicker positioning when scroll-snap is toggled
+ requestAnimationFrame(() => {
+ this.sliding.set(false);
+ });
+ });
+ return;
+ }
+
+ const visibleElements: Element[] = Object.values(this.galleryRef.visibleItems()).map((entry: IntersectionObserverEntry) => entry.target);
+
+ // Get the diff between the viewport size and the smallest visible item size
+ const diffSize: number = Object.values(this.galleryRef.visibleItems()).reduce((total: number, entry: IntersectionObserverEntry) => {
+ return Math.max(total, (this._viewport.clientWidth - entry.boundingClientRect.width) / 2);
+ }, 0);
+
+ const options: IntersectionObserverInit = {
+ root: this._viewport,
+ threshold: 0,
+ rootMargin: `0px ${ -diffSize }px 0px ${ -diffSize }px`
+ };
+
+ createIntersectionObserver(options, visibleElements).pipe(
+ take(1)
+ ).subscribe((entries: IntersectionObserverEntry[]) => {
+
+ const centerElement: IntersectionObserverEntry = entries
+ .filter((entry: IntersectionObserverEntry) => entry.isIntersecting)
+ .reduce((acc: IntersectionObserverEntry, entry: IntersectionObserverEntry) => {
+ return acc ? acc.intersectionRatio > entry.intersectionRatio ? acc : entry : entry;
+ }, null);
+
+ this._zone.run(() => {
+ // this.isSlidingChange.emit(false);
+ const index: number = +centerElement.target.getAttribute('galleryIndex');
+ this.galleryRef.set(index);
+ // Tiny delay is needed to avoid flicker positioning when scroll-snap is toggled
+ requestAnimationFrame(() => {
+ this.sliding.set(false);
+ });
+ });
})
});
});
- });
- }
- }
- private _unsubscribe(): void {
- this._mc?.destroy();
+ onCleanup(() => mc?.destroy());
+ });
+ });
}
private getIndexOnMouseUp(e: any, adapter: SliderAdapter): number {
const currIndex: number = this.galleryRef.currIndex();
- // Check if scrolled item is great enough to navigate
- const currElement: Element = this.items()[currIndex].nativeElement;
-
- // Find the gallery item element in the center elements
- const elementAtCenter: Element = this.getElementFromViewportCenter();
-
- console.log(elementAtCenter)
- // Check if center item can be taken from element using
- if (elementAtCenter && elementAtCenter !== currElement) {
- return +elementAtCenter.getAttribute('galleryIndex');
- }
const velocity: number = adapter.getHammerVelocity(e);
// Check if velocity is great enough to navigate
@@ -158,19 +162,4 @@ export class HammerSliding implements OnDestroy {
// Reset position to the current index
return -1;
}
-
- private getElementFromViewportCenter(): Element {
- // Get slider position relative to the document
- const sliderRect: DOMRect = this._viewport.getBoundingClientRect();
- // Try look for the center item using `elementsFromPoint` function
- const centerElements: Element[] = this._document.elementsFromPoint(
- sliderRect.x + (sliderRect.width / 2),
- sliderRect.y + (sliderRect.height / 2)
- );
- console.log(centerElements);
- // Find the gallery item element in the center elements
- return centerElements.find((element: Element) => {
- return element.getAttribute('galleryId') === this.galleryId();
- });
- }
}
diff --git a/projects/ng-gallery/src/lib/models/config.model.ts b/projects/ng-gallery/src/lib/models/config.model.ts
index 0b8a8cae..9cee03ce 100644
--- a/projects/ng-gallery/src/lib/models/config.model.ts
+++ b/projects/ng-gallery/src/lib/models/config.model.ts
@@ -82,6 +82,7 @@ interface SliderConfig {
boxTemplate?: TemplateRef;
itemTemplate?: TemplateRef;
imageTemplate?: TemplateRef;
+ centralized?: boolean;
}
export type GalleryConfig = SliderConfig
diff --git a/projects/ng-gallery/src/lib/models/slider.model.ts b/projects/ng-gallery/src/lib/models/slider.model.ts
index 4cee0c52..094986c1 100644
--- a/projects/ng-gallery/src/lib/models/slider.model.ts
+++ b/projects/ng-gallery/src/lib/models/slider.model.ts
@@ -7,3 +7,8 @@ export interface WorkerState {
value: number;
instant: boolean;
}
+
+export interface IndexChange {
+ index: number;
+ behavior: ScrollBehavior;
+}
diff --git a/projects/ng-gallery/src/lib/observers/active-item-observer.ts b/projects/ng-gallery/src/lib/observers/active-item-observer.ts
index 1ac6ff64..4bed1d38 100644
--- a/projects/ng-gallery/src/lib/observers/active-item-observer.ts
+++ b/projects/ng-gallery/src/lib/observers/active-item-observer.ts
@@ -1,40 +1,37 @@
import { Observable, Subscriber, mergeMap, filter, map } from 'rxjs';
-export class ActiveItemObserver {
+// export class ActiveItemObserver {
+//
+// observe(root: HTMLElement, elements: HTMLElement[], rootMargin: string): Observable {
+// return createIntersectionObserver(root, elements, rootMargin).pipe(
+// map((entry: IntersectionObserverEntry) => {
+// if (entry.isIntersecting) {
+// entry.target.classList.add('g-item-highlight');
+// return +entry.target.getAttribute('galleryIndex');
+// } else {
+// entry.target.classList.remove('g-item-highlight');
+// return -1;
+// }
+// }),
+// filter((index: number) => index !== -1)
+// );
+// }
+// }
- observe(root: HTMLElement, elements: HTMLElement[], rootMargin: string): Observable {
- return createIntersectionObserver(root, elements, rootMargin).pipe(
- map((entry: IntersectionObserverEntry) => {
- if (entry.isIntersecting) {
- entry.target.classList.add('g-item-highlight');
- return +entry.target.getAttribute('galleryIndex');
- } else {
- entry.target.classList.remove('g-item-highlight');
- return -1;
- }
- }),
- filter((index: number) => index !== -1)
- );
- }
-}
-
-function createIntersectionObserver(root: HTMLElement, elements: HTMLElement[], rootMargin: string, threshold: number = 1): Observable {
+export function createIntersectionObserver(options: IntersectionObserverInit, elements: Element[]): Observable {
return new Observable((observer: Subscriber) => {
const intersectionObserver: IntersectionObserver = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => observer.next(entries),
- {
- root,
- rootMargin,
- threshold
- }
+ options
);
elements.forEach((element: HTMLElement) => intersectionObserver.observe(element));
return () => {
elements.forEach((element: HTMLElement) => intersectionObserver.unobserve(element));
intersectionObserver.disconnect();
};
- }).pipe(
- mergeMap((entries: IntersectionObserverEntry[]) => entries)
- );
+ });
+ // .pipe(
+ // mergeMap((entries: IntersectionObserverEntry[]) => entries)
+ // );
}
diff --git a/projects/ng-gallery/src/lib/observers/intersection-sensor.directive.ts b/projects/ng-gallery/src/lib/observers/intersection-sensor.directive.ts
new file mode 100644
index 00000000..3c7ab855
--- /dev/null
+++ b/projects/ng-gallery/src/lib/observers/intersection-sensor.directive.ts
@@ -0,0 +1,134 @@
+import {
+ Directive,
+ inject,
+ effect,
+ computed,
+ untracked,
+ Signal,
+ NgZone,
+ ElementRef,
+ EffectCleanupRegisterFn
+} from '@angular/core';
+import { Subscription } from 'rxjs';
+import { GalleryConfig } from '../models/config.model';
+import { GalleryRef } from '../services/gallery-ref';
+import { SliderAdapter } from '../components/adapters';
+import { GalleryItemComponent } from '../components/gallery-item.component';
+import { createIntersectionObserver } from './active-item-observer';
+import { SmoothScroll } from '../smooth-scroll';
+import { HammerSliding } from '../gestures/hammer-sliding.directive';
+import { SliderComponent } from '../components/slider/slider';
+
+/**
+ * This observer used to detect when a slider element reaches the active soon
+ */
+@Directive({
+ standalone: true,
+ selector: '[intersectionSensor]'
+})
+export class IntersectionSensor {
+
+ private readonly zone: NgZone = inject(NgZone);
+
+ private readonly galleryRef: GalleryRef = inject(GalleryRef);
+
+ private readonly smoothScroll: SmoothScroll = inject(SmoothScroll);
+
+ private readonly hammerSlider: HammerSliding = inject(HammerSliding);
+
+ private readonly nativeElement: HTMLElement = inject(ElementRef).nativeElement;
+
+ private readonly slider: SliderComponent = inject(SliderComponent, { self: true });
+
+ readonly disableInteractionObserver: Signal = computed(() => {
+ return this.smoothScroll.scrolling() || this.hammerSlider.sliding(); // || this.resizeSensor.isResizing();
+ });
+
+ constructor() {
+ let visibleItemsObserver$: Subscription;
+ let activeItemObserver$: Subscription;
+
+ effect((onCleanup: EffectCleanupRegisterFn) => {
+ const config: GalleryConfig = this.galleryRef.config();
+ const items: ReadonlyArray = this.slider.items();
+ const adapter: SliderAdapter = this.slider.adapter();
+
+ if (!adapter || !items.length) return;
+
+ untracked(() => {
+ const rootMargin: string = adapter.getRootMargin();
+ if (config.debug) {
+ this.nativeElement.style.setProperty('--intersection-margin', `"INTERSECTION(${ rootMargin })"`);
+ }
+
+ this.zone.runOutsideAngular(() => {
+ const options: IntersectionObserverInit = { root: this.nativeElement, threshold: 0, rootMargin };
+ const elements: HTMLElement[] = items.map((item: GalleryItemComponent) => item.nativeElement);
+
+ visibleItemsObserver$ = createIntersectionObserver(options, elements).subscribe((entries: IntersectionObserverEntry[]) => {
+ const visibleItems: Record = this.galleryRef.visibleItems();
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ entry.target.classList.add('g-item-highlight');
+ visibleItems[+entry.target.getAttribute('galleryIndex')] = entry;
+ } else {
+ entry.target.classList.remove('g-item-highlight');
+ delete visibleItems[+entry.target.getAttribute('galleryIndex')];
+ }
+ });
+ this.zone.run(() => {
+ this.galleryRef.visibleItems.set({ ...visibleItems });
+ });
+ });
+ });
+
+ onCleanup(() => visibleItemsObserver$?.unsubscribe());
+ });
+ });
+
+ effect((onCleanup) => {
+ const disabled: boolean = this.disableInteractionObserver();
+ const visibleElements: Record = this.galleryRef.visibleItems();
+
+ if (disabled) return;
+
+ untracked(() => {
+
+ const elements = Object.values(visibleElements).map(x => x.target);
+
+ // Get the diff between the viewport size and the smallest visible item size
+ const diffSize: number = Object.values(this.galleryRef.visibleItems()).reduce((total: number, entry: IntersectionObserverEntry) => {
+ return Math.min(total, (this.nativeElement.clientWidth - entry.boundingClientRect.width) / 2);
+ }, 0);
+
+ const options: IntersectionObserverInit = {
+ root: this.nativeElement,
+ threshold: .99,
+ rootMargin: `0px ${ -diffSize }px 0px ${ -diffSize }px`
+ };
+
+ this.zone.runOutsideAngular(() => {
+ activeItemObserver$ = createIntersectionObserver(options, elements).subscribe((entries: IntersectionObserverEntry[]) => {
+
+ const centerElement: IntersectionObserverEntry = entries
+ .filter((entry: IntersectionObserverEntry) => entry.isIntersecting)
+ .reduce((acc: IntersectionObserverEntry, entry: IntersectionObserverEntry) => {
+ return acc ? acc.intersectionRatio > entry.intersectionRatio ? acc : entry : entry;
+ }, null);
+
+ if (!centerElement) return;
+
+ const index: number = +centerElement.target.getAttribute('galleryIndex');
+
+ if (index === this.galleryRef.currIndex()) return;
+
+ // Set the new current index
+ this.zone.run(() => this.galleryRef.currIndex.set(index));
+ });
+ });
+
+ onCleanup(() => activeItemObserver$?.unsubscribe())
+ });
+ });
+ }
+}
diff --git a/projects/ng-gallery/src/lib/observers/item-intersection-observer.directive.ts b/projects/ng-gallery/src/lib/observers/item-intersection-observer.directive.ts
deleted file mode 100644
index 2b298b0b..00000000
--- a/projects/ng-gallery/src/lib/observers/item-intersection-observer.directive.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import {
- Directive,
- inject,
- effect,
- output,
- input,
- NgZone,
- InputSignal,
- OutputEmitterRef,
- EffectCleanupRegisterFn
-} from '@angular/core';
-import { Subscription, combineLatest, filter, switchMap } from 'rxjs';
-import { ActiveItemObserver } from './active-item-observer';
-import { resizeObservable } from '../utils/resize-observer';
-import { SliderAdapter } from '../components/adapters';
-import { GalleryItemComponent } from '../components/gallery-item.component';
-import { ItemState } from '../components/templates/items.model';
-import { GalleryRef } from '../services/gallery-ref';
-import { GalleryConfig } from '../models/config.model';
-
-@Directive({
- standalone: true,
- selector: '[itemIntersectionObserver]'
-})
-export class ItemIntersectionObserver {
-
- private _galleryRef: GalleryRef = inject(GalleryRef);
-
- private _zone: NgZone = inject(NgZone);
-
- private _item: GalleryItemComponent = inject(GalleryItemComponent);
-
- private _sensor: ActiveItemObserver = new ActiveItemObserver();
-
- adapter: InputSignal = input();
-
- disabled: InputSignal = input(false, { alias: 'itemIntersectionObserverDisabled' });
-
- activeIndexChange: OutputEmitterRef = output();
-
- private get _viewport(): HTMLElement {
- return this._item.nativeElement.parentElement.parentElement;
- }
-
- constructor() {
- let intersectionSub$: Subscription;
-
- effect((onCleanup: EffectCleanupRegisterFn) => {
- const config: GalleryConfig = this._galleryRef.config();
- const adapter: SliderAdapter = this.adapter();
-
- intersectionSub$?.unsubscribe();
-
- if (config.itemAutosize && !this.disabled() && adapter) {
- this._zone.runOutsideAngular(() => {
- intersectionSub$ = combineLatest([
- resizeObservable(this._viewport),
- resizeObservable(this._item.nativeElement)
- ]).pipe(
- switchMap(() => this._item.state$),
- filter((state: ItemState) => state !== 'loading'),
- switchMap(() => {
- const rootMargin: string = adapter.getElementRootMargin(this._viewport, this._item.nativeElement);
- if (config.debug) {
- this._item.nativeElement.style.setProperty('--item-intersection-margin', `"VIEWPORT(${ this._viewport.clientWidth }x${ this._viewport.clientHeight }) ITEM(${ this._item.nativeElement.clientWidth }x${ this._item.nativeElement.clientHeight }) INTERSECTION(${ rootMargin })"`);
- }
-
- return this._sensor.observe(
- this._viewport,
- [this._item.nativeElement],
- rootMargin
- );
- }
- )
- ).subscribe((index: number) => {
- this._zone.run(() => this.activeIndexChange.emit(index));
- });
- });
- }
- onCleanup(() => intersectionSub$?.unsubscribe());
- });
- }
-}
diff --git a/projects/ng-gallery/src/lib/observers/slider-intersection-observer.directive.ts b/projects/ng-gallery/src/lib/observers/slider-intersection-observer.directive.ts
deleted file mode 100644
index 1df312f2..00000000
--- a/projects/ng-gallery/src/lib/observers/slider-intersection-observer.directive.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import {
- Directive,
- Output,
- inject,
- effect,
- untracked,
- input,
- EventEmitter,
- NgZone,
- ElementRef,
- InputSignal,
- EffectCleanupRegisterFn
-} from '@angular/core';
-import { Subscription } from 'rxjs';
-import { GalleryConfig } from '../models/config.model';
-import { GalleryRef } from '../services/gallery-ref';
-import { ActiveItemObserver } from './active-item-observer';
-import { SliderAdapter } from '../components/adapters';
-import { GalleryItemComponent } from '../components/gallery-item.component';
-
-@Directive({
- standalone: true,
- selector: '[sliderIntersectionObserver]'
-})
-export class SliderIntersectionObserver {
-
- private _zone: NgZone = inject(NgZone);
-
- readonly galleryRef: GalleryRef = inject(GalleryRef);
-
- private _viewport: HTMLElement = inject(ElementRef).nativeElement;
-
- private _sensor: ActiveItemObserver = new ActiveItemObserver();
-
- adapter: InputSignal = input();
-
- items: InputSignal = input();
-
- disabled: InputSignal = input(false, { alias: 'sliderIntersectionObserverDisabled' });
-
- @Output() activeIndexChange: EventEmitter = new EventEmitter();
-
- constructor() {
- let sub$: Subscription;
- effect((onCleanup: EffectCleanupRegisterFn) => {
- const config: GalleryConfig = this.galleryRef.config();
- const disabled: boolean = this.disabled();
- const items: GalleryItemComponent[] = this.items();
- const adapter: SliderAdapter = this.adapter();
-
- untracked(() => {
- if (!config.itemAutosize && !disabled && adapter && items.length) {
- const rootMargin: string = adapter.getRootMargin();
- if (config.debug) {
- this._viewport.style.setProperty('--intersection-margin', `"INTERSECTION(${ rootMargin })"`);
- }
-
- this._zone.runOutsideAngular(() => {
- sub$ = this._sensor.observe(
- this._viewport,
- items.map((item: GalleryItemComponent) => item.nativeElement),
- rootMargin
- ).subscribe((index: number) => {
- console.log('😆', index);
- this._zone.run(() => this.activeIndexChange.emit(index));
- });
- });
- }
-
- onCleanup(() => sub$?.unsubscribe());
- });
- });
- }
-}
diff --git a/projects/ng-gallery/src/lib/observers/slider-resize-observer.directive.ts b/projects/ng-gallery/src/lib/observers/slider-resize-observer.directive.ts
index c471d741..cdcb2b82 100644
--- a/projects/ng-gallery/src/lib/observers/slider-resize-observer.directive.ts
+++ b/projects/ng-gallery/src/lib/observers/slider-resize-observer.directive.ts
@@ -2,17 +2,19 @@ import {
Directive,
output,
inject,
- computed,
effect,
+ computed,
+ untracked,
input,
- NgZone,
- ElementRef,
Signal,
InputSignal,
- AfterViewChecked,
OnInit,
+ NgZone,
+ ElementRef,
+ DestroyRef,
OutputEmitterRef,
- EffectCleanupRegisterFn
+ AfterViewChecked,
+ EffectCleanupRegisterFn, signal, WritableSignal
} from '@angular/core';
import {
Observable,
@@ -33,13 +35,19 @@ import { resizeObservable } from '../utils/resize-observer';
import { SliderAdapter } from '../components/adapters';
import { GalleryRef } from '../services/gallery-ref';
import { GalleryConfig } from '../models/config.model';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Directive({
+ host: {
+ '[class.g-resizing]': 'isResizing()'
+ },
standalone: true,
selector: '[sliderResizeObserver]'
})
export class SliderResizeObserver implements AfterViewChecked, OnInit {
+ readonly isResizing: WritableSignal = signal(false);
+
private readonly _galleryRef: GalleryRef = inject(GalleryRef);
private readonly _viewport: HTMLElement = inject(ElementRef).nativeElement;
@@ -65,8 +73,6 @@ export class SliderResizeObserver implements AfterViewChecked, OnInit {
(config.thumbPosition === 'top' || config.thumbPosition === 'bottom');
});
- galleryId: InputSignal = input();
-
adapter: InputSignal = input();
isResizingChange: OutputEmitterRef = output();
@@ -74,89 +80,101 @@ export class SliderResizeObserver implements AfterViewChecked, OnInit {
constructor() {
let resizeSubscription$: Subscription;
let _autoHeightSubscription: Subscription;
+ const destroyRef = inject(DestroyRef);
effect((onCleanup: EffectCleanupRegisterFn) => {
- resizeSubscription$?.unsubscribe();
-
- this._zone.runOutsideAngular(() => {
- // Detect if the size of the slider has changed detecting current index on scroll
- resizeSubscription$ = resizeObservable(this._viewport, (observer: ResizeObserver) => this._resizeObserver = observer).pipe(
- // Check if resize should skip due to re-observing the slider
- filter(() => !this._shouldSkip || !(this._shouldSkip = false)),
- // Immediately set visibility to hidden to avoid changing the active item caused by appearance of other items when size is expanded
- tap(() => this.setResizingState()),
- debounceTime(this._galleryRef.config().resizeDebounceTime, animationFrameScheduler),
- tap(async (entry: ResizeObserverEntry) => {
- // Update CSS variables with the proper values
- this.updateSliderSize();
-
- if (this._isAutoHeight()) {
- const img: HTMLImageElement = await firstValueFrom(this._imgManager.getActiveItem());
- // If img height is identical to the viewport height then skip
- if (img.height === this._viewport.clientHeight) {
- this.resetResizingState();
- } else {
- // Unobserve the slider while the height is being changed
- this.setResizingState({ unobserve: true });
- // Change the height
- this._galleryCore.style.setProperty('--slider-height', `${ img.height }px`);
- // Wait until height transition ends
- await firstValueFrom(this._afterHeightChanged$);
- this.resetResizingState({
- // Mark to skip first emit after re-observing the slider if height content rect height and client height are identical
- shouldSkip: entry.contentRect.height === this._viewport.clientHeight,
- observe: true
- });
- }
- } else {
- requestAnimationFrame(() => this.resetResizingState({ shouldSkip: true }));
- }
- })
- ).subscribe();
- });
- onCleanup(() => resizeSubscription$?.unsubscribe());
- });
+ const config = this._galleryRef.config();
+ const isAutoHeight = this._isAutoHeight();
-
- effect((onCleanup: EffectCleanupRegisterFn) => {
- this._shouldSkip = false;
- if (this._isAutoHeight()) {
+ untracked(() => {
this._zone.runOutsideAngular(() => {
- _autoHeightSubscription = this._imgManager.getActiveItem().pipe(
- switchMap((img: HTMLImageElement) => {
- this.setResizingState({ unobserve: true });
- this._galleryCore.style.setProperty('--slider-height', `${ img.clientHeight }px`);
-
- // Check if the new item height is equal to the current height, there will be no transition,
- // So reset resizing state
- if (img.height === this._viewport.clientHeight) {
- this.resetResizingState({ shouldSkip: true, observe: true });
- return EMPTY;
+ // Detect if the size of the slider has changed detecting current index on scroll
+ resizeSubscription$ = resizeObservable(this._viewport, (observer: ResizeObserver) => this._resizeObserver = observer).pipe(
+ takeUntilDestroyed(destroyRef),
+ // Check if resize should skip due to re-observing the slider
+ filter(() => !this._shouldSkip || !(this._shouldSkip = false)),
+ // Immediately set visibility to hidden to avoid changing the active item caused by appearance of other items when size is expanded
+ tap(() => this.setResizingState()),
+ debounceTime(config.resizeDebounceTime, animationFrameScheduler),
+ tap(async (entry: ResizeObserverEntry) => {
+ // Update CSS variables with the proper values
+ this.updateSliderSize();
+
+ if (isAutoHeight) {
+ const img: HTMLImageElement = await firstValueFrom(this._imgManager.getActiveItem());
+ // If img height is identical to the viewport height then skip
+ if (img.height === this._viewport.clientHeight) {
+ this.resetResizingState();
+ } else {
+ // Unobserve the slider while the height is being changed
+ this.setResizingState({ unobserve: true });
+ // Change the height
+ this._galleryCore.style.setProperty('--slider-height', `${ img.height }px`);
+ // Wait until height transition ends
+ await firstValueFrom(this._afterHeightChanged$);
+ this.resetResizingState({
+ // Mark to skip first emit after re-observing the slider if height content rect height and client height are identical
+ shouldSkip: entry.contentRect.height === this._viewport.clientHeight,
+ observe: true
+ });
+ }
+ } else {
+ requestAnimationFrame(() => this.resetResizingState({ shouldSkip: true }));
}
- return this._afterHeightChanged$.pipe(
- tap(() => this.resetResizingState({ shouldSkip: true, observe: true })),
- take(1)
- );
})
).subscribe();
});
- }
- onCleanup(() => resizeSubscription$?.unsubscribe());
+
+ onCleanup(() => resizeSubscription$?.unsubscribe());
+ });
});
+
+
+ // effect((onCleanup: EffectCleanupRegisterFn) => {
+ // const isAutoHeight = this._isAutoHeight();
+ //
+ // untracked(() => {
+ // this._shouldSkip = false;
+ // if (isAutoHeight) {
+ // this._zone.runOutsideAngular(() => {
+ // _autoHeightSubscription = this._imgManager.getActiveItem().pipe(
+ // takeUntilDestroyed(destroyRef),
+ // switchMap((img: HTMLImageElement) => {
+ // this.setResizingState({ unobserve: true });
+ // this._galleryCore.style.setProperty('--slider-height', `${ img.clientHeight }px`);
+ //
+ // // Check if the new item height is equal to the current height, there will be no transition,
+ // // So reset resizing state
+ // if (img.height === this._viewport.clientHeight) {
+ // this.resetResizingState({ shouldSkip: true, observe: true });
+ // return EMPTY;
+ // }
+ // return this._afterHeightChanged$.pipe(
+ // tap(() => this.resetResizingState({ shouldSkip: true, observe: true })),
+ // take(1)
+ // );
+ // })
+ // ).subscribe();
+ // });
+ // }
+ //
+ // onCleanup(() => resizeSubscription$?.unsubscribe());
+ // });
+ // });
}
ngOnInit(): void {
// Check if height has transition for the auto-height feature
- const transitionDuration: string = getComputedStyle(this._viewport).getPropertyValue('transition-duration');
- if (parseFloat(transitionDuration) === 0) {
- this._afterHeightChanged$ = of(null);
- } else {
- this._afterHeightChanged$ = fromEvent(this._viewport, 'transitionend');
- }
+ // const transitionDuration: string = getComputedStyle(this._viewport).getPropertyValue('transition-duration');
+ // if (parseFloat(transitionDuration) === 0) {
+ // this._afterHeightChanged$ = of(null);
+ // } else {
+ // this._afterHeightChanged$ = fromEvent(this._viewport, 'transitionend');
+ // }
}
ngAfterViewChecked(): void {
- this.updateSliderSize();
+ // this.updateSliderSize();
}
private updateSliderSize(): void {
@@ -180,9 +198,10 @@ export class SliderResizeObserver implements AfterViewChecked, OnInit {
private setResizingState({ unobserve }: { unobserve?: boolean } = {}): void {
this._zone.run(() => {
+ this.isResizing.set(true);
this.isResizingChange.emit(true);
})
- this._viewport.classList.add('g-resizing');
+ // this._viewport.classList.add('g-resizing');
if (unobserve) {
// Unobserve the slider while the height is being changed
this._resizeObserver.unobserve(this._viewport);
@@ -191,9 +210,10 @@ export class SliderResizeObserver implements AfterViewChecked, OnInit {
private resetResizingState({ shouldSkip, observe }: { shouldSkip?: boolean, observe?: boolean } = {}): void {
this._zone.run(() => {
+ this.isResizing.set(false);
this.isResizingChange.emit(false);
})
- this._viewport.classList.remove('g-resizing');
+ // this._viewport.classList.remove('g-resizing');
this._shouldSkip = shouldSkip;
if (observe) {
this._resizeObserver.observe(this._viewport);
diff --git a/projects/ng-gallery/src/lib/observers/thumb-resize-observer.directive.ts b/projects/ng-gallery/src/lib/observers/thumb-resize-observer.directive.ts
deleted file mode 100644
index 404c1cf9..00000000
--- a/projects/ng-gallery/src/lib/observers/thumb-resize-observer.directive.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import {
- Directive,
- effect,
- inject,
- output,
- input,
- NgZone,
- ElementRef,
- InputSignal,
- OutputEmitterRef,
- EffectCleanupRegisterFn
-} from '@angular/core';
-import { Subscription, tap, debounceTime, animationFrameScheduler } from 'rxjs';
-import { resizeObservable } from '../utils/resize-observer';
-import { GalleryConfig } from '../models/config.model';
-import { SliderAdapter } from '../components/adapters';
-
-@Directive({
- standalone: true,
- selector: '[thumbResizeObserver]'
-})
-export class ThumbResizeObserver {
-
- private readonly _viewport: HTMLElement = inject(ElementRef).nativeElement;
-
- private readonly _zone: NgZone = inject(NgZone);
-
- config: InputSignal = input();
-
- adapter: InputSignal = input();
-
- resized: OutputEmitterRef = output({ alias: 'thumbResizeObserver' });
-
- constructor() {
- let resizeSubscription$: Subscription;
-
- effect((onCleanup: EffectCleanupRegisterFn) => {
- if (!resizeSubscription$) {
- this.updateSliderSize();
- } else {
- resizeSubscription$?.unsubscribe();
- }
-
- this._zone.runOutsideAngular(() => {
- resizeSubscription$ = resizeObservable(this._viewport).pipe(
- debounceTime(this.config().resizeDebounceTime, animationFrameScheduler),
- tap(() => {
- this.updateSliderSize();
- this.resized.emit();
- })
- ).subscribe();
- });
-
- onCleanup(() => resizeSubscription$?.unsubscribe());
- });
- }
-
- private updateSliderSize(): void {
- this._viewport.style.setProperty('--thumb-centralize-start-size', this.adapter().getCentralizerStartSize() + 'px');
- this._viewport.style.setProperty('--thumb-centralize-end-size', this.adapter().getCentralizerEndSize() + 'px');
- }
-}
diff --git a/projects/ng-gallery/src/lib/services/gallery-ref.ts b/projects/ng-gallery/src/lib/services/gallery-ref.ts
index 4699b1e8..4ad8feba 100644
--- a/projects/ng-gallery/src/lib/services/gallery-ref.ts
+++ b/projects/ng-gallery/src/lib/services/gallery-ref.ts
@@ -1,8 +1,8 @@
-import { computed, inject, Injectable, OnDestroy, signal, Signal, WritableSignal } from '@angular/core';
+import { Injectable, computed, inject, signal, Signal, WritableSignal } from '@angular/core';
+import { toObservable } from '@angular/core/rxjs-interop';
import { Observable, Subject } from 'rxjs';
import { GalleryError, GalleryItem } from '../models/gallery.model';
import { GALLERY_CONFIG, GalleryConfig } from '../models/config.model';
-import { GalleryAction } from '../models/constants';
import {
IframeItem,
IframeItemData,
@@ -13,10 +13,10 @@ import {
YoutubeItem,
YoutubeItemData
} from '../components/templates/items.model';
-import { toObservable } from '@angular/core/rxjs-interop';
+import { IndexChange } from '../models/slider.model';
@Injectable()
-export class GalleryRef implements OnDestroy {
+export class GalleryRef {
/** Stream that emits on item click */
readonly itemClick: Subject = new Subject();
@@ -35,6 +35,8 @@ export class GalleryRef implements OnDestroy {
/** Gallery Events */
+ readonly visibleItems: WritableSignal> = signal({});
+
readonly items: WritableSignal = signal([]);
readonly currIndex: WritableSignal = signal(0);
@@ -43,12 +45,12 @@ export class GalleryRef implements OnDestroy {
readonly scrollBehavior: WritableSignal = signal(null);
- readonly action: WritableSignal = signal(null);
-
readonly hasNext: Signal = computed(() => this.currIndex() < this.items().length);
readonly hasPrev: Signal = computed(() => this.currIndex() > 0);
+ readonly indexChange: Subject = new Subject();
+
/** Config signal */
readonly config: WritableSignal = signal(inject(GALLERY_CONFIG));
@@ -134,11 +136,11 @@ export class GalleryRef implements OnDestroy {
console.error(`[NgGallery]: Unable to set the active item because the given index (${ i }) is outside the items range!`);
return;
}
- this.currIndex.set(i);
+ // this.currIndex.set(i);
if (behavior) {
this.scrollBehavior.set(behavior);
}
- this.indexChanged.next();
+ this.indexChange.next({ index: i, behavior });
}
/**
@@ -189,12 +191,4 @@ export class GalleryRef implements OnDestroy {
this.items.set([]);
}
- /**
- * Destroy gallery
- */
- ngOnDestroy(): void {
- this.itemClick.complete();
- this.thumbClick.complete();
- }
-
}
diff --git a/projects/ng-gallery/src/lib/services/hammer.ts b/projects/ng-gallery/src/lib/services/hammer.ts
index e505552c..f6688035 100644
--- a/projects/ng-gallery/src/lib/services/hammer.ts
+++ b/projects/ng-gallery/src/lib/services/hammer.ts
@@ -16,5 +16,5 @@ export class CustomHammerConfig extends HammerGestureConfig {
rotate: { enable: false }
};
- options = { inputClass: Hammer.MouseInput };
+ options = { inputClass: typeof Hammer !== 'undefined' ? Hammer.MouseInput : null };
}
diff --git a/projects/ng-gallery/src/lib/services/resize-sensor.ts b/projects/ng-gallery/src/lib/services/resize-sensor.ts
index f665a342..c6ce0693 100644
--- a/projects/ng-gallery/src/lib/services/resize-sensor.ts
+++ b/projects/ng-gallery/src/lib/services/resize-sensor.ts
@@ -3,50 +3,89 @@ import {
signal,
inject,
effect,
- ElementRef,
+ computed,
+ untracked,
+ NgZone,
+ Signal,
WritableSignal,
- EffectCleanupRegisterFn, OutputEmitterRef, output
+ EffectCleanupRegisterFn
} from '@angular/core';
-import { Observable, Subscriber, Subscription, debounceTime, mergeMap, animationFrameScheduler } from 'rxjs';
+import { SharedResizeObserver } from '@angular/cdk/observers/private';
+import { Subscription, animationFrameScheduler, throttleTime, combineLatest } from 'rxjs';
+import { GalleryConfig } from '../models/config.model';
import { GalleryRef } from './gallery-ref';
-import { outputFromObservable, toObservable } from '@angular/core/rxjs-interop';
+import { SliderComponent } from '../components/slider/slider';
@Directive({
standalone: true,
- selector: '[resizeSensor]'
+ selector: '[resizeSensor]',
+ host: {
+ '[style.--slider-width.px]': 'slideSize()?.width',
+ '[style.--slider-height.px]': 'slideSize()?.height',
+ '[style.--centralize-start-size.px]': 'centralizeStart()',
+ '[style.--centralize-end-size.px]': 'centralizeEnd()'
+ }
})
export class ResizeSensor {
- private readonly nativeElement: HTMLElement = inject(ElementRef).nativeElement;
+ private readonly sharedResizeObserver: SharedResizeObserver = inject(SharedResizeObserver)
+
+ private readonly slider: SliderComponent = inject(SliderComponent, { self: true });
+
+ private readonly zone: NgZone = inject(NgZone);
private readonly galleryRef: GalleryRef = inject(GalleryRef);
- size: WritableSignal = signal(null);
+ readonly slideSize: WritableSignal = signal(null);
- // TODO: rethink if it is better to just emit to output directly without converting the signal to observable
- resizeSensor: OutputEmitterRef = output();
+ readonly contentSize: WritableSignal = signal(null);
+
+ readonly centralizeStart: Signal = computed(() => {
+ if (!this.slideSize() || !this.contentSize()) return;
+ return this.slider.adapter()?.getCentralizerStartSize();
+ });
+
+ readonly centralizeEnd: Signal = computed(() => {
+ if (!this.slideSize() || !this.contentSize()) return;
+ return this.slider.adapter()?.getCentralizerEndSize();
+ });
constructor() {
let resizeSubscription$: Subscription;
effect((onCleanup: EffectCleanupRegisterFn) => {
- resizeSubscription$?.unsubscribe();
-
- resizeSubscription$ = new Observable((subscriber: Subscriber) => {
- const resizeObserver: ResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => subscriber.next(entries));
- resizeObserver.observe(this.nativeElement);
- return () => resizeObserver.disconnect();
- }).pipe(
- mergeMap((entries: ResizeObserverEntry[]) => entries),
- debounceTime(this.galleryRef.config().resizeDebounceTime, animationFrameScheduler),
- ).subscribe((entry: ResizeObserverEntry) => {
- if (entry.contentRect.height) {
- this.size.set(entry.contentRect);
- this.resizeSensor.emit(entry.contentRect);
- }
- });
+ const config: GalleryConfig = this.galleryRef.config();
- onCleanup(() => resizeSubscription$?.unsubscribe());
+ // Make sure items are rendered
+ if (!this.slider.items().length) return;
+
+ untracked(() => {
+ this.zone.runOutsideAngular(() => {
+ resizeSubscription$ = combineLatest([
+ this.sharedResizeObserver.observe(this.slider.nativeElement),
+ this.sharedResizeObserver.observe(this.slider.nativeElement.firstElementChild)
+ ]).pipe(
+ throttleTime(config.resizeDebounceTime, animationFrameScheduler, {
+ leading: true,
+ trailing: true
+ }),
+ ).subscribe(([sliderEntries, contentEntries]: [ResizeObserverEntry[], ResizeObserverEntry[]]) => {
+ this.zone.run(() => {
+ if (!sliderEntries || !contentEntries) return;
+
+ if (sliderEntries[0].contentRect.height) {
+ this.slideSize.set(sliderEntries[0].contentRect);
+ }
+
+ if (contentEntries[0].contentRect.height) {
+ this.contentSize.set(contentEntries[0].contentRect);
+ }
+ });
+ });
+ });
+
+ onCleanup(() => resizeSubscription$?.unsubscribe());
+ });
});
}
}
diff --git a/projects/ng-gallery/src/lib/services/scroll-snap-type.ts b/projects/ng-gallery/src/lib/services/scroll-snap-type.ts
index e189132c..ebc9aa49 100644
--- a/projects/ng-gallery/src/lib/services/scroll-snap-type.ts
+++ b/projects/ng-gallery/src/lib/services/scroll-snap-type.ts
@@ -1,7 +1,7 @@
-import { computed, Directive, effect, inject, input, InputSignal, Signal } from '@angular/core';
+import { computed, Directive, inject, Signal } from '@angular/core';
import { HammerSliding } from '../gestures/hammer-sliding.directive';
import { SmoothScroll } from '../smooth-scroll';
-import { SliderAdapter } from '../components/adapters';
+import { SliderComponent } from '../components/slider/slider';
@Directive({
standalone: true,
@@ -16,19 +16,12 @@ export class ScrollSnapType {
private readonly hammerSliding: HammerSliding = inject(HammerSliding, { self: true });
- adapter: InputSignal = input();
+ private readonly slider: SliderComponent = inject(SliderComponent, { self: true });
scrollSnapType: Signal = computed(() => {
if (this.smoothScroll.scrolling() || this.hammerSliding.sliding()) {
return 'none';
}
- return this.adapter().scrollSnapType;
+ return this.slider.adapter().scrollSnapType;
});
-
- constructor() {
- effect(() => {
- console.log(this.scrollSnapType())
- });
- }
-
}
diff --git a/projects/ng-gallery/src/lib/services/slider-centraliser.ts b/projects/ng-gallery/src/lib/services/slider-centraliser.ts
deleted file mode 100644
index 3ea1dfa7..00000000
--- a/projects/ng-gallery/src/lib/services/slider-centraliser.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Directive, inject, computed, input, Signal, InputSignal } from '@angular/core';
-import { SliderAdapter } from '../components/adapters';
-import { ResizeSensor } from './resize-sensor';
-
-@Directive({
- standalone: true,
- selector: '[sliderCentralizer]',
- host: {
- '[style.--centralize-start-size]': 'centralizeStart()',
- '[style.--centralize-end-size]': 'centralizeEnd()'
- }
-})
-export class SliderCentraliser {
-
- readonly resizeSensor: ResizeSensor = inject(ResizeSensor, { self: true });
-
- adapter: InputSignal = input();
-
- centralizeStart: Signal = computed(() => {
- if (this.resizeSensor.size()) {
- return `${ this.adapter().getCentralizerStartSize() }px`;
- }
- });
-
- centralizeEnd: Signal = computed(() => {
- if (this.resizeSensor.size()) {
- return `${ this.adapter().getCentralizerEndSize() }px`;
- }
- });
-}
diff --git a/projects/ng-gallery/src/lib/smooth-scroll/smooth-scroll.directive.ts b/projects/ng-gallery/src/lib/smooth-scroll/smooth-scroll.directive.ts
index cde0fe11..5c9be3ca 100644
--- a/projects/ng-gallery/src/lib/smooth-scroll/smooth-scroll.directive.ts
+++ b/projects/ng-gallery/src/lib/smooth-scroll/smooth-scroll.directive.ts
@@ -1,28 +1,21 @@
import {
Directive,
- Output,
inject,
+ signal,
effect,
- input,
- EventEmitter,
NgZone,
- OnInit,
- OnDestroy,
ElementRef,
- InputSignal,
- signal,
- WritableSignal,
- untracked
+ WritableSignal
} from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { Directionality } from '@angular/cdk/bidi';
import { _Bottom, _Left, _Right, _Top, _Without } from '@angular/cdk/scrolling';
import { getRtlScrollAxisType, RtlScrollAxisType } from '@angular/cdk/platform';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
Observable,
Subject,
Subscriber,
- Subscription,
of,
take,
merge,
@@ -35,10 +28,10 @@ import {
} from 'rxjs';
import BezierEasing from './bezier-easing';
import { SmoothScrollOptions, SmoothScrollStep, SmoothScrollToOptions } from './index';
-import { SliderAdapter } from '../components/adapters';
import { GalleryRef } from '../services/gallery-ref';
-
-declare const Hammer: any;
+import { IndexChange } from '../models/slider.model';
+import { SliderComponent } from '../components/slider/slider';
+import { HammerSliding } from '../gestures/hammer-sliding.directive';
@Directive({
standalone: true,
@@ -47,9 +40,13 @@ declare const Hammer: any;
'[class.g-scrolling]': 'scrolling()'
}
})
-export class SmoothScroll implements OnInit, OnDestroy {
+export class SmoothScroll {
- private galleryRef: GalleryRef = inject(GalleryRef);
+ private readonly galleryRef: GalleryRef = inject(GalleryRef);
+
+ private readonly slider: SliderComponent = inject(SliderComponent, { self: true });
+
+ private readonly hammerSlider: HammerSliding = inject(HammerSliding, { self: true });
private readonly _zone: NgZone = inject(NgZone);
@@ -59,17 +56,10 @@ export class SmoothScroll implements OnInit, OnDestroy {
private readonly _w: Window = inject(DOCUMENT).defaultView;
- /** HammerJS instance */
- private _hammer: any;
-
private readonly _scrollController: Subject = new Subject();
private readonly _finished: Subject = new Subject();
- private _isInterruptedByMouse: boolean;
-
- private _subscription: Subscription;
-
/**
* Timing method
*/
@@ -77,58 +67,45 @@ export class SmoothScroll implements OnInit, OnDestroy {
return this._w.performance?.now?.bind(this._w.performance) || Date.now;
}
- isInterruptedByMouse: WritableSignal = signal(false);
+ private readonly interruptedByMouse$: Subject = new Subject();
scrolling: WritableSignal = signal(false);
- position: InputSignal = input(null, { alias: 'smoothScroll' });
-
- adapter: InputSignal = input();
-
- // Whether mouse sliding is enabled
- hammerSliding: InputSignal = input();
-
- @Output() isScrollingChange: EventEmitter = new EventEmitter();
-
constructor() {
effect(() => {
- if (this.position()) {
- untracked(() => {
- this._zone.runOutsideAngular(() => {
- this.scrollTo(this.position());
- });
- });
- }
+ if (!this.hammerSlider.sliding()) return;
+ this.interruptedByMouse$.next();
});
- }
- ngOnInit(): void {
- this._subscription = this._scrollController.pipe(
- switchMap((context: SmoothScrollStep) => {
- this._zone.run(() => {
- this.isScrollingChange.emit(true);
- this.scrolling.set(true);
- });
-
- // this._el.classList.add('g-scrolling');
- // this._el.style.setProperty('--slider-scroll-snap-type', 'none');
-
- // Scroll each step recursively
- return of(null).pipe(
- expand(() => this._step(context).pipe(
- takeWhile((currContext: SmoothScrollStep) => this._isFinished(currContext)),
- takeUntil(this._finished)
- )),
- finalize(() => this.resetElement()),
- takeUntil(this._interrupted()),
- );
- })
- ).subscribe();
- }
+ this._zone.runOutsideAngular(() => {
+ this.galleryRef.indexChange.pipe(takeUntilDestroyed()).subscribe((change: IndexChange) => {
+ const el: HTMLElement = this.slider.items()[change.index]?.nativeElement;
+ const scrollBehavior: ScrollBehavior = this.galleryRef.config().scrollBehavior;
+ if (el) {
+ const pos: SmoothScrollOptions = this.slider.adapter().getScrollToValue(el, change.behavior || scrollBehavior);
+ this.scrollTo(pos);
+ }
+ });
+
+ this._scrollController.pipe(
+ takeUntilDestroyed(),
+ switchMap((context: SmoothScrollStep) => {
+ this._zone.run(() => {
+ this.scrolling.set(true);
+ });
- ngOnDestroy(): void {
- this._subscription?.unsubscribe();
- this._scrollController.complete();
+ // Scroll each step recursively
+ return of(null).pipe(
+ expand(() => this._step(context).pipe(
+ takeWhile((currContext: SmoothScrollStep) => this._isFinished(currContext)),
+ takeUntil(this._finished)
+ )),
+ finalize(() => this.resetElement()),
+ takeUntil(this._interrupted()),
+ );
+ })
+ ).subscribe();
+ });
}
/**
@@ -141,16 +118,8 @@ export class SmoothScroll implements OnInit, OnDestroy {
private resetElement(): void {
this._zone.run(() => {
- this.isScrollingChange.emit(false);
this.scrolling.set(false);
- // this.isInterruptedByMouse.set(false);
});
-
- // this._el.classList.remove('g-scrolling');
- // if (!this._isInterruptedByMouse) {
- // this._el.style.setProperty('--slider-scroll-snap-type', this.adapter().scrollSnapType);
- // }
- // this._isInterruptedByMouse = false;
}
/**
@@ -168,36 +137,11 @@ export class SmoothScroll implements OnInit, OnDestroy {
* Terminates an ongoing smooth scroll
*/
private _interrupted(): Observable {
- let interrupt$: Observable;
- if (this.hammerSliding() && typeof Hammer !== 'undefined') {
- this._hammer = new Hammer(this._el, { inputClass: Hammer.MouseInput });
- this._hammer.get('pan').set({ direction: this.adapter().hammerDirection });
-
- // For gallery thumb slider, dragging thumbnails should cancel the ongoing scroll
- interrupt$ = merge(
- new Observable((subscriber: Subscriber) => {
- this._hammer.on('panstart', () => {
- // this._isInterruptedByMouse = true;
- this.isInterruptedByMouse.set(true);
- subscriber.next();
- subscriber.complete();
- });
- return () => {
- this._hammer.destroy();
- }
- }),
- fromEvent(this._el, 'wheel', { passive: true, capture: true }),
- fromEvent(this._el, 'touchmove', { passive: true, capture: true }),
- )
- } else {
- interrupt$ = merge(
- fromEvent(this._el, 'wheel', { passive: true, capture: true }),
- fromEvent(this._el, 'touchmove', { passive: true, capture: true }),
- )
- }
- return interrupt$.pipe(
- take(1)
- );
+ return merge(
+ this.interruptedByMouse$,
+ fromEvent(this._el, 'wheel', { passive: true, capture: true }),
+ fromEvent(this._el, 'touchmove', { passive: true, capture: true }),
+ ).pipe(take(1));
}
/**
diff --git a/projects/ng-gallery/src/lib/styles/debug.scss b/projects/ng-gallery/src/lib/styles/debug.scss
index 6d60c5fa..d18dc3a0 100644
--- a/projects/ng-gallery/src/lib/styles/debug.scss
+++ b/projects/ng-gallery/src/lib/styles/debug.scss
@@ -62,6 +62,10 @@
}
}
+ .g-slider-observed {
+ display: block !important;
+ }
+
.g-slider-debug {
position: absolute;
top: 0;
@@ -82,6 +86,10 @@
background: rgb(31, 108, 185);
}
+ .g-slider-observed {
+ background: rgb(31, 185, 139);
+ }
+
div, &:before {
display: none;
color: white;
diff --git a/projects/ng-gallery/src/lib/tests/common.ts b/projects/ng-gallery/src/lib/tests/common.ts
new file mode 100644
index 00000000..f5acea8f
--- /dev/null
+++ b/projects/ng-gallery/src/lib/tests/common.ts
@@ -0,0 +1,32 @@
+import { Component, Signal, viewChild } from '@angular/core';
+import { GalleryComponent, GalleryItem, ImageItem } from 'ng-gallery';
+
+@Component({
+ standalone: true,
+ imports: [GalleryComponent],
+ template: `
+
+ `
+})
+export class TestComponent {
+ items: GalleryItem[] = [
+ new ImageItem({
+ src: 'https://loremflickr.com/200/200?random=1',
+ }),
+ new ImageItem({
+ src: 'https://loremflickr.com/200/200?random=2',
+ }),
+ new ImageItem({
+ src: 'https://loremflickr.com/200/200?random=3',
+ })
+ ];
+ width: number = 500;
+ height: number = 300;
+
+ gallery: Signal = viewChild(GalleryComponent);
+}
+
+export async function afterTimeout(timeout: number): Promise {
+ // Use await with a setTimeout promise
+ await new Promise((resolve) => setTimeout(resolve, timeout));
+}
diff --git a/projects/ng-gallery/src/lib/tests/gallery.spec.ts b/projects/ng-gallery/src/lib/tests/gallery.spec.ts
new file mode 100644
index 00000000..5b9bcdd7
--- /dev/null
+++ b/projects/ng-gallery/src/lib/tests/gallery.spec.ts
@@ -0,0 +1,57 @@
+import { ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing';
+import { DebugElement } from '@angular/core';
+import { By } from '@angular/platform-browser';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { GalleryItemComponent } from '../components/gallery-item.component';
+import { TestComponent } from './common';
+
+
+describe('Gallery component', () => {
+ let fixture: ComponentFixture;
+ let component: TestComponent;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ NoopAnimationsModule,
+ TestComponent
+ ],
+ providers: [
+ { provide: ComponentFixtureAutoDetect, useValue: true }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create gallery', () => {
+ expect(component.gallery()).toBeTruthy();
+ });
+
+ it('should load and render items in the gallery', () => {
+ expect(component.gallery().galleryRef.items()).toBe(component.items);
+ const items: DebugElement[] = fixture.debugElement.queryAll(By.directive(GalleryItemComponent));
+ expect(items.length).toBe(3);
+ });
+});
+
+// it('should trigger pan event', () => {
+// // Find the element
+// const pannableElement = fixture.debugElement.query(By.css('.pannable')).nativeElement;
+//
+// // Create a mock Pan event
+// const panEvent = new Event('pan');
+// Object.assign(panEvent, {
+// deltaX: 100, // Pan distance in X axis
+// deltaY: 0, // Pan distance in Y axis
+// type: 'pan',
+// });
+//
+// // Dispatch the event
+// pannableElement.dispatchEvent(panEvent);
+//
+// // Assert the expected behavior
+// expect(component.panEventTriggered).toBeTrue();
+// });
diff --git a/projects/ng-gallery/src/lib/tests/resize-directive.spec.ts b/projects/ng-gallery/src/lib/tests/resize-directive.spec.ts
new file mode 100644
index 00000000..354674f2
--- /dev/null
+++ b/projects/ng-gallery/src/lib/tests/resize-directive.spec.ts
@@ -0,0 +1,88 @@
+import { ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { By } from '@angular/platform-browser';
+import { DebugElement } from '@angular/core';
+import { afterTimeout, TestComponent } from './common';
+import { SliderComponent } from '../components/slider/slider';
+import { ResizeSensor } from '../services/resize-sensor';
+
+describe('Resize sensor directive', () => {
+ let fixture: ComponentFixture;
+ let component: TestComponent;
+ let resizeSensorDirective: ResizeSensor;
+ let sliderComponent: SliderComponent;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ NoopAnimationsModule,
+ TestComponent
+ ],
+ providers: [
+ { provide: ComponentFixtureAutoDetect, useValue: true }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ const resizeSensorElement: DebugElement = fixture.debugElement.query(By.directive(ResizeSensor));
+ resizeSensorDirective = resizeSensorElement.injector.get(ResizeSensor);
+
+ const sliderComponentElement: DebugElement = fixture.debugElement.query(By.directive(SliderComponent));
+ sliderComponent = sliderComponentElement.componentInstance;
+ });
+
+ it('should create [resizeSensor] directive', () => {
+ expect(resizeSensorDirective).toBeTruthy();
+ });
+
+ it('should compute "centralizeStart" size when content >= viewport', async () => {
+ await afterTimeout(0);
+ expect(resizeSensorDirective.centralizeStart()).toBe(0);
+ expect(resizeSensorDirective.centralizeStart()).toBe(0);
+ expect(sliderComponent.nativeElement.style.getPropertyValue('--centralize-start-size')).toBe('0px');
+ expect(sliderComponent.nativeElement.style.getPropertyValue('--centralize-end-size')).toBe('0px');
+ });
+
+ it('should compute "centralizeStart" size when content < viewport', async () => {
+ sliderComponent.galleryRef.setConfig({
+ itemAutosize: true,
+ centralized: true
+ });
+ component.width = 800;
+ component.height = 200;
+ // TODO: Find a promise that resolves when all items are loaded and displayed
+ await afterTimeout(200);
+
+ expect(resizeSensorDirective.centralizeStart()).toBe(100);
+ expect(resizeSensorDirective.centralizeStart()).toBe(100);
+ expect(sliderComponent.nativeElement.style.getPropertyValue('--centralize-start-size')).toBe('100px');
+ expect(sliderComponent.nativeElement.style.getPropertyValue('--centralize-end-size')).toBe('100px');
+ });
+
+ it('should compute "centralizeStart" size when content >= viewport', async () => {
+ await afterTimeout(0);
+ expect(resizeSensorDirective.centralizeStart()).toBe(0);
+ expect(resizeSensorDirective.centralizeStart()).toBe(0);
+ expect(sliderComponent.nativeElement.style.getPropertyValue('--centralize-start-size')).toBe('0px');
+ expect(sliderComponent.nativeElement.style.getPropertyValue('--centralize-end-size')).toBe('0px');
+ });
+
+ it('should update the size signal when component size changes', async () => {
+ await afterTimeout(0);
+ expect(resizeSensorDirective.slideSize().width).toBe(500);
+ expect(resizeSensorDirective.slideSize().height).toBe(300);
+ expect(sliderComponent.nativeElement.style.getPropertyValue('--slider-width')).toBe('500px');
+ expect(sliderComponent.nativeElement.style.getPropertyValue('--slider-height')).toBe('300px');
+
+ component.width = 400;
+ fixture.detectChanges();
+ await afterTimeout(20);
+
+ expect(resizeSensorDirective.slideSize().width).toBe(400);
+ expect(sliderComponent.nativeElement.style.getPropertyValue('--slider-width')).toBe('400px');
+ });
+
+});
diff --git a/projects/ng-gallery/src/lib/tests/scroll-snap-type.spec.ts b/projects/ng-gallery/src/lib/tests/scroll-snap-type.spec.ts
new file mode 100644
index 00000000..3e6a75e5
--- /dev/null
+++ b/projects/ng-gallery/src/lib/tests/scroll-snap-type.spec.ts
@@ -0,0 +1,74 @@
+import { ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { By } from '@angular/platform-browser';
+import { DebugElement } from '@angular/core';
+import { TestComponent } from './common';
+import { ScrollSnapType } from '../services/scroll-snap-type';
+import { SmoothScroll } from '../smooth-scroll';
+import { HammerSliding } from '../gestures/hammer-sliding.directive';
+import { SliderComponent } from '../components/slider/slider';
+
+describe('Scroll snap type directive', () => {
+ let fixture: ComponentFixture;
+ let scrollSnapTypeDirective: ScrollSnapType;
+ let smoothScrollDirective: SmoothScroll;
+ let hammerSliderDirective: HammerSliding;
+ let sliderComponent: SliderComponent;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ NoopAnimationsModule,
+ TestComponent
+ ],
+ providers: [
+ { provide: ComponentFixtureAutoDetect, useValue: true }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestComponent);
+ fixture.detectChanges();
+
+ const smoothScrollElement: DebugElement = fixture.debugElement.query(By.directive(SmoothScroll));
+ smoothScrollDirective = smoothScrollElement.injector.get(SmoothScroll);
+
+ const scrollSnapTypeElement: DebugElement = fixture.debugElement.query(By.directive(ScrollSnapType));
+ scrollSnapTypeDirective = scrollSnapTypeElement.injector.get(ScrollSnapType);
+
+ const hammerSliderElement: DebugElement = fixture.debugElement.query(By.directive(HammerSliding));
+ hammerSliderDirective = hammerSliderElement.injector.get(HammerSliding);
+
+ const sliderComponentElement: DebugElement = fixture.debugElement.query(By.directive(SliderComponent));
+ sliderComponent = sliderComponentElement.componentInstance;
+ });
+
+ it('should create [scrollSnapType] directive', () => {
+ expect(scrollSnapTypeDirective).toBeTruthy();
+ });
+
+ it('should compute "scrollSnapType" to none when gallery is scrolling', () => {
+ smoothScrollDirective.scrolling.set(true);
+ fixture.detectChanges();
+
+ expect(scrollSnapTypeDirective.scrollSnapType()).toBe('none');
+ expect(sliderComponent.nativeElement.style.getPropertyValue('--slider-scroll-snap-type')).toBe('none');
+ });
+
+ it('should compute "scrollSnapType" to none when gallery is sliding', () => {
+ hammerSliderDirective.sliding.set(true);
+ fixture.detectChanges();
+
+ expect(scrollSnapTypeDirective.scrollSnapType()).toBe('none');
+ expect(sliderComponent.nativeElement.style.getPropertyValue('--slider-scroll-snap-type')).toBe('none');
+ });
+
+ it('should compute "scrollSnapType" to adapter scroll snap type value', () => {
+ smoothScrollDirective.scrolling.set(false);
+ hammerSliderDirective.sliding.set(false);
+ fixture.detectChanges();
+
+ expect(scrollSnapTypeDirective.scrollSnapType()).toBe(sliderComponent.adapter().scrollSnapType);
+ expect(sliderComponent.nativeElement.style.getPropertyValue('--slider-scroll-snap-type')).toBe(sliderComponent.adapter().scrollSnapType);
+ });
+
+});
diff --git a/projects/ng-gallery/src/lib/tests/slider.spec.ts b/projects/ng-gallery/src/lib/tests/slider.spec.ts
new file mode 100644
index 00000000..b39f0ad7
--- /dev/null
+++ b/projects/ng-gallery/src/lib/tests/slider.spec.ts
@@ -0,0 +1,66 @@
+import { ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { By } from '@angular/platform-browser';
+import { DebugElement } from '@angular/core';
+import { GalleryItemComponent } from '../components/gallery-item.component';
+import { TestComponent } from './common';
+import { SliderComponent } from '../components/slider/slider';
+import { HorizontalAdapter, VerticalAdapter } from '../components/adapters';
+
+describe('Gallery slider', () => {
+ let fixture: ComponentFixture;
+ let component: TestComponent;
+ let sliderComponent: SliderComponent;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ NoopAnimationsModule,
+ TestComponent
+ ],
+ providers: [
+ { provide: ComponentFixtureAutoDetect, useValue: true }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ const sliderComponentElement: DebugElement = fixture.debugElement.query(By.directive(SliderComponent));
+ sliderComponent = sliderComponentElement.componentInstance;
+ });
+
+ it('should create slider component with default class and attributes', () => {
+ expect(sliderComponent).toBeTruthy();
+ expect(sliderComponent.nativeElement.classList).toContain('g-slider');
+ expect(sliderComponent.nativeElement.getAttribute('centralised')).toBe('false');
+ expect(sliderComponent.nativeElement.getAttribute('orientation')).toBe('horizontal');
+ expect(sliderComponent.nativeElement.getAttribute('autosize')).toBe('false');
+ });
+
+ it('should use horizontal adapter when orientation config specifies "horizontal"', () => {
+ component.gallery().galleryRef.setConfig({
+ orientation: 'horizontal'
+ });
+ fixture.detectChanges();
+
+ expect(sliderComponent.nativeElement.getAttribute('orientation')).toBe('horizontal');
+ expect(sliderComponent.adapter()).toBeInstanceOf(HorizontalAdapter);
+ });
+
+ it('should use vertical adapter when orientation config specifies "vertical"', () => {
+ component.gallery().galleryRef.setConfig({
+ orientation: 'vertical'
+ });
+ fixture.detectChanges();
+
+ expect(sliderComponent.nativeElement.getAttribute('orientation')).toBe('vertical');
+ expect(sliderComponent.adapter()).toBeInstanceOf(VerticalAdapter);
+ });
+
+ it('should render the items loaded in the gallery', () => {
+ const items: DebugElement[] = fixture.debugElement.queryAll(By.directive(GalleryItemComponent));
+ expect(items.length).toBe(component.gallery().galleryRef.items().length);
+ });
+});
diff --git a/projects/ng-gallery/src/lib/tests/smooth-scroll.spec.ts b/projects/ng-gallery/src/lib/tests/smooth-scroll.spec.ts
new file mode 100644
index 00000000..24c9bfe7
--- /dev/null
+++ b/projects/ng-gallery/src/lib/tests/smooth-scroll.spec.ts
@@ -0,0 +1,96 @@
+import { ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { By } from '@angular/platform-browser';
+import { DebugElement } from '@angular/core';
+import { GalleryRef } from 'ng-gallery';
+import { afterTimeout, TestComponent } from './common';
+import { SmoothScroll, SmoothScrollOptions } from '../smooth-scroll';
+
+describe('Smooth scroll directive', () => {
+ let fixture: ComponentFixture;
+ let nativeElement: HTMLElement;
+ let smoothScrollDirective: SmoothScroll;
+ let galleryRef: GalleryRef;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ NoopAnimationsModule,
+ TestComponent
+ ],
+ providers: [
+ { provide: ComponentFixtureAutoDetect, useValue: true }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestComponent);
+ fixture.detectChanges();
+
+ const smoothScrollElement: DebugElement = fixture.debugElement.query(By.directive(SmoothScroll));
+ smoothScrollDirective = smoothScrollElement.injector.get(SmoothScroll);
+ nativeElement = smoothScrollElement.nativeElement;
+
+ galleryRef = smoothScrollElement.injector.get(GalleryRef);
+ });
+
+ it('should create [smoothScroll] directive', () => {
+ expect(smoothScrollDirective).toBeTruthy();
+ });
+
+ it('should toggle scrolling class with scrolling signal', async () => {
+ expect(smoothScrollDirective.scrolling()).toBe(false);
+ expect(nativeElement.classList.contains('g-scrolling')).toBeFalse();
+
+ // Trigger index change
+ galleryRef.set(1, 'auto');
+ fixture.detectChanges();
+ expect(smoothScrollDirective.scrolling()).toBe(true);
+ expect(nativeElement.classList.contains('g-scrolling')).toBeTrue();
+
+ await afterTimeout(20);
+ expect(smoothScrollDirective.scrolling()).toBe(false);
+ expect(nativeElement.classList.contains('g-scrolling')).toBeFalse();
+ });
+
+ it('should scroll instantly to target item on gallery index changes', async () => {
+ const scrollToSpy: jasmine.Spy = spyOn(smoothScrollDirective, 'scrollTo').and.callThrough();
+ await afterTimeout(16);
+
+ // Trigger index change
+ galleryRef.set(1, 'auto');
+
+ expect(smoothScrollDirective.scrolling()).toBe(true);
+
+ await afterTimeout(50);
+
+ const pos: SmoothScrollOptions = {
+ start: 500,
+ behavior: 'auto'
+ };
+
+ expect(scrollToSpy).toHaveBeenCalledWith(pos);
+ expect(galleryRef.currIndex()).toBe(1);
+ expect(smoothScrollDirective.scrolling()).toBe(false);
+ });
+
+ it('should scroll smoothly to target item on gallery index changes', async () => {
+ const scrollToSpy: jasmine.Spy = spyOn(smoothScrollDirective, 'scrollTo').and.callThrough();
+ await afterTimeout(16);
+
+ // Trigger index change
+ galleryRef.set(2, 'smooth');
+
+ expect(smoothScrollDirective.scrolling()).toBe(true);
+
+ await afterTimeout(500);
+
+ const pos: SmoothScrollOptions = {
+ start: 1000,
+ behavior: 'smooth'
+ };
+ expect(scrollToSpy).toHaveBeenCalledWith(pos);
+ expect(galleryRef.currIndex()).toBe(2);
+ expect(smoothScrollDirective.scrolling()).toBe(false);
+ });
+
+});
diff --git a/projects/ng-gallery/src/lib/utils/gallery.default.ts b/projects/ng-gallery/src/lib/utils/gallery.default.ts
index b7d5916c..f4b52dce 100644
--- a/projects/ng-gallery/src/lib/utils/gallery.default.ts
+++ b/projects/ng-gallery/src/lib/utils/gallery.default.ts
@@ -46,6 +46,7 @@ export const defaultConfig: GalleryConfig = {
y2: 1
},
thumbCentralized: false,
+ centralized: false,
thumbAutosize: false,
itemAutosize: false,
autoHeight: false,
diff --git a/projects/ng-gallery/src/lib/utils/img-recognizer.ts b/projects/ng-gallery/src/lib/utils/img-recognizer.ts
index 2e40d57e..3cd45940 100644
--- a/projects/ng-gallery/src/lib/utils/img-recognizer.ts
+++ b/projects/ng-gallery/src/lib/utils/img-recognizer.ts
@@ -1,4 +1,13 @@
-import { Directive, inject, effect, input, ElementRef, InputSignal, EffectCleanupRegisterFn } from '@angular/core';
+import {
+ Directive,
+ inject,
+ effect,
+ untracked,
+ input,
+ ElementRef,
+ InputSignal,
+ EffectCleanupRegisterFn
+} from '@angular/core';
import { ImgManager } from './img-manager';
import { GalleryItemComponent } from '../components/gallery-item.component';
@@ -31,14 +40,16 @@ export class ImgRecognizer {
effect((onCleanup: EffectCleanupRegisterFn) => {
const index: number = this.index();
- if (index != null) {
- this.manager.addItem(index, {
- state$: this.item.state$,
- target: this.nativeElement
- });
+ untracked(() => {
+ if (index != null) {
+ this.manager.addItem(index, {
+ state$: this.item.state$,
+ target: this.nativeElement
+ });
- onCleanup(() => this.manager.deleteItem(index));
- }
+ onCleanup(() => this.manager.deleteItem(index));
+ }
+ });
});
}
}