ng-images-preview 1.0.3 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,31 +1,137 @@
1
1
  import * as i0 from '@angular/core';
2
- import { input, viewChild, signal, computed, inject, PLATFORM_ID, effect, HostListener, ChangeDetectionStrategy, Component, ApplicationRef, EnvironmentInjector, ElementRef, createComponent, Input, Directive } from '@angular/core';
2
+ import { input, viewChildren, signal, computed, inject, PLATFORM_ID, effect, HostListener, ChangeDetectionStrategy, Component, ApplicationRef, EnvironmentInjector, ElementRef, createComponent, Input, Directive, NgModule } from '@angular/core';
3
3
  import * as i1 from '@angular/common';
4
4
  import { isPlatformBrowser, CommonModule } from '@angular/common';
5
5
  import { trigger, transition, style, animate } from '@angular/animations';
6
+ import { DomSanitizer } from '@angular/platform-browser';
6
7
 
8
+ const PREVIEW_ICONS = {
9
+ close: '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>',
10
+ prev: '<svg viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>',
11
+ next: '<svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>',
12
+ flipH: '<svg viewBox="0 0 24 24"><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8 20h2V1h-2v22zm8-6h2v-2h-2v2zM15 5h2V3h-2v2zm4 8h2v-2h-2v2zm0 8c1.1 0 2-.9 2-2h-2v2z"/></svg>',
13
+ flipV: '<svg viewBox="0 0 24 24"><path d="M7 21h2v-2H7v2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-12h2V7h-2v2zm-4 4h2v-4h-2v4zm-8-8h2V3H7v2zm8 0h2V3h-2v2zm-4 8h4v-4h-4v4zM3 15h2v-2H3v2zm12-4h2v-4H3v4h2v-2h10v2zM7 3v2h2V3H7zm8 20h2v-2h-2v2zm-4 0h2v-2h-2v2zm4-12h2v-2h-2v2zM3 19c0 1.1.9 2 2 2h2v-2H5v-2H3v2zm16-6h2v-2h-2v2zm0 4v2c0 1.1-.9 2-2 2h-2v-2h2v-2h2zM5 3c-1.1 0-2 .9-2 2h2V3z"/></svg>',
14
+ rotateLeft: '<svg viewBox="0 0 24 24"><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>',
15
+ rotateRight: '<svg viewBox="0 0 24 24"><path d="M15.55 5.55L11 1v3.07C7.06 4.56 4 7.92 4 12s3.05 7.44 7 7.93v-2.02c-2.84-.48-5-2.94-5-5.91s2.16-5.43 5-5.91V10l4.55-4.45zM19.93 11c-.17-1.39-.72-2.73-1.62-3.89l-1.42 1.42c.54.75.88 1.6 1.02 2.47h2.02zM13 17.9v2.02c1.39-.17 2.74-.71 3.9-1.61l-1.44-1.44c-.75.54-1.59.89-2.46 1.03zm3.89-2.42l1.42 1.41c.9-1.16 1.45-2.5 1.62-3.89h-2.02c-.14.87-.48 1.72-1.02 2.48z"/></svg>',
16
+ zoomOut: '<svg viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>',
17
+ zoomIn: '<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>'
18
+ };
19
+
20
+ /**
21
+ * Image Preview Overlay Component.
22
+ * Displays images in a full-screen overlay with zoom, rotate, and pan capabilities.
23
+ */
7
24
  class ImagesPreviewComponent {
25
+ /**
26
+ * The image source to display.
27
+ * @required
28
+ */
8
29
  src = input.required(...(ngDevMode ? [{ debugName: "src" }] : []));
30
+ /**
31
+ * List of image URLs for gallery navigation.
32
+ */
9
33
  images = input(...(ngDevMode ? [undefined, { debugName: "images" }] : []));
34
+ /**
35
+ * Optional srcset for the single image `src`.
36
+ */
37
+ srcset = input(...(ngDevMode ? [undefined, { debugName: "srcset" }] : []));
38
+ /**
39
+ * List of srcsets corresponding to the `images` array.
40
+ * Must be 1:1 mapped with `images`.
41
+ */
42
+ srcsets = input(...(ngDevMode ? [undefined, { debugName: "srcsets" }] : []));
43
+ /**
44
+ * Initial index for gallery navigation.
45
+ * @default 0
46
+ */
10
47
  initialIndex = input(0, ...(ngDevMode ? [{ debugName: "initialIndex" }] : []));
48
+ /**
49
+ * DOMRect of the element that opened the preview.
50
+ * Used for FLIP animation calculation.
51
+ */
52
+ openerRect = input(undefined, ...(ngDevMode ? [{ debugName: "openerRect" }] : []));
53
+ /**
54
+ * Custom template to render instead of the default viewer.
55
+ * Exposes `ImagesPreviewContext`.
56
+ */
11
57
  customTemplate = input(...(ngDevMode ? [undefined, { debugName: "customTemplate" }] : []));
58
+ /**
59
+ * Configuration for the toolbar buttons.
60
+ */
61
+ toolbarConfig = input({
62
+ showZoom: true,
63
+ showRotate: true,
64
+ showFlip: true,
65
+ }, ...(ngDevMode ? [{ debugName: "toolbarConfig" }] : []));
66
+ /**
67
+ * Callback function called when the preview is closed.
68
+ */
12
69
  closeCallback = () => {
13
70
  /* noop */
14
71
  };
15
- imgRef = viewChild('imgRef', ...(ngDevMode ? [{ debugName: "imgRef" }] : []));
72
+ imgRefs = viewChildren('imgRef', ...(ngDevMode ? [{ debugName: "imgRefs" }] : []));
73
+ thumbRefs = viewChildren('thumbRef', ...(ngDevMode ? [{ debugName: "thumbRefs" }] : []));
16
74
  // State signals
17
75
  currentIndex = signal(0, ...(ngDevMode ? [{ debugName: "currentIndex" }] : []));
18
- isLoading = signal(true, ...(ngDevMode ? [{ debugName: "isLoading" }] : []));
76
+ // isLoading = signal(true); // Replaced by per-image tracking
19
77
  hasError = signal(false, ...(ngDevMode ? [{ debugName: "hasError" }] : []));
20
- activeSrc = computed(() => {
78
+ // Track loaded state for each image source
79
+ loadedImages = signal(new Set(), ...(ngDevMode ? [{ debugName: "loadedImages" }] : []));
80
+ // Toggle thumbnails
81
+ showThumbnails = input(true, ...(ngDevMode ? [{ debugName: "showThumbnails" }] : []));
82
+ // Toggle toolbar
83
+ showToolbar = input(true, ...(ngDevMode ? [{ debugName: "showToolbar" }] : []));
84
+ // Toolbar custom extensions
85
+ toolbarExtensions = input(null, ...(ngDevMode ? [{ debugName: "toolbarExtensions" }] : []));
86
+ activeBuffer = computed(() => {
21
87
  const imgs = this.images();
22
- if (imgs && imgs.length > 0) {
23
- return imgs[this.currentIndex()];
88
+ const srcsets = this.srcsets();
89
+ const current = this.currentIndex();
90
+ // Single image or fallback
91
+ if (!imgs || imgs.length === 0) {
92
+ return [{ src: this.src(), index: 0, offset: 0, srcset: this.srcset() }];
93
+ }
94
+ const buffer = [];
95
+ const total = imgs.length;
96
+ // Helper to get srcset safely
97
+ const getSrcset = (i) => (srcsets && srcsets[i]) ? srcsets[i] : undefined;
98
+ // Prev (-1)
99
+ if (current > 0) {
100
+ buffer.push({
101
+ src: imgs[current - 1],
102
+ index: current - 1,
103
+ offset: -1,
104
+ srcset: getSrcset(current - 1)
105
+ });
106
+ }
107
+ // Current (0)
108
+ buffer.push({
109
+ src: imgs[current],
110
+ index: current,
111
+ offset: 0,
112
+ srcset: getSrcset(current)
113
+ });
114
+ // Next (+1)
115
+ if (current < total - 1) {
116
+ buffer.push({
117
+ src: imgs[current + 1],
118
+ index: current + 1,
119
+ offset: 1,
120
+ srcset: getSrcset(current + 1)
121
+ });
24
122
  }
25
- return this.src();
26
- }, ...(ngDevMode ? [{ debugName: "activeSrc" }] : []));
123
+ return buffer;
124
+ }, ...(ngDevMode ? [{ debugName: "activeBuffer" }] : []));
27
125
  platformId = inject(PLATFORM_ID);
126
+ sanitizer = inject(DomSanitizer);
127
+ // Icons map with SafeHtml
128
+ icons;
28
129
  constructor() {
130
+ // Sanitize icons once
131
+ this.icons = Object.keys(PREVIEW_ICONS).reduce((acc, key) => {
132
+ acc[key] = this.sanitizer.bypassSecurityTrustHtml(PREVIEW_ICONS[key]);
133
+ return acc;
134
+ }, {});
29
135
  // defined in class body usually, but here to show logical grouping
30
136
  effect(() => {
31
137
  // Initialize index from input
@@ -34,7 +140,6 @@ class ImagesPreviewComponent {
34
140
  effect(() => {
35
141
  // Reset state when index changes
36
142
  this.currentIndex();
37
- this.isLoading.set(true);
38
143
  this.hasError.set(false);
39
144
  this.reset(); // Reset zoom/rotate
40
145
  }, { allowSignalWrites: true });
@@ -43,19 +148,53 @@ class ImagesPreviewComponent {
43
148
  const index = this.currentIndex();
44
149
  const images = this.images();
45
150
  if (isPlatformBrowser(this.platformId) && images && images.length > 0) {
151
+ const srcsets = this.srcsets();
152
+ const getSrcset = (i) => (srcsets && srcsets[i]) ? srcsets[i] : undefined;
46
153
  // Next
47
154
  if (index < images.length - 1) {
48
155
  const img = new Image();
156
+ const s = getSrcset(index + 1);
157
+ if (s)
158
+ img.srcset = s;
49
159
  img.src = images[index + 1];
50
160
  }
51
161
  // Prev
52
162
  if (index > 0) {
53
163
  const img = new Image();
164
+ const s = getSrcset(index - 1);
165
+ if (s)
166
+ img.srcset = s;
54
167
  img.src = images[index - 1];
55
168
  }
56
169
  }
57
170
  });
171
+ effect(() => {
172
+ // Handle FLIP on open
173
+ const opener = this.openerRect();
174
+ if (opener && !this.flipAnimDone) {
175
+ // Defer to let the view render so we know final position
176
+ setTimeout(() => this.runFlipAnimation(opener));
177
+ }
178
+ });
179
+ // Auto-scroll thumbnails
180
+ effect(() => {
181
+ const index = this.currentIndex();
182
+ const show = this.showThumbnails();
183
+ const refs = this.thumbRefs();
184
+ if (show && refs && refs[index]) {
185
+ const el = refs[index].nativeElement;
186
+ const container = el.parentElement;
187
+ if (container) {
188
+ const scrollLeft = el.offsetLeft + el.offsetWidth / 2 - container.offsetWidth / 2;
189
+ container.scrollTo({
190
+ left: scrollLeft,
191
+ behavior: 'smooth'
192
+ });
193
+ }
194
+ }
195
+ });
58
196
  }
197
+ flipAnimDone = false;
59
198
  scale = signal(1, ...(ngDevMode ? [{ debugName: "scale" }] : []));
60
199
  translateX = signal(0, ...(ngDevMode ? [{ debugName: "translateX" }] : []));
61
200
  translateY = signal(0, ...(ngDevMode ? [{ debugName: "translateY" }] : []));
@@ -63,10 +202,11 @@ class ImagesPreviewComponent {
63
202
  flipH = signal(false, ...(ngDevMode ? [{ debugName: "flipH" }] : []));
64
203
  flipV = signal(false, ...(ngDevMode ? [{ debugName: "flipV" }] : []));
65
204
  isDragging = signal(false, ...(ngDevMode ? [{ debugName: "isDragging" }] : []));
205
+ isInertia = signal(false, ...(ngDevMode ? [{ debugName: "isInertia" }] : []));
66
206
  // Touch state
67
207
  initialPinchDistance = 0;
68
208
  initialScale = 1;
69
- isPinching = false;
209
+ isPinching = signal(false, ...(ngDevMode ? [{ debugName: "isPinching" }] : []));
70
210
  // Computed state object for template
71
211
  state = computed(() => ({
72
212
  src: this.src(),
@@ -74,8 +214,10 @@ class ImagesPreviewComponent {
74
214
  rotate: this.rotate(),
75
215
  flipH: this.flipH(),
76
216
  flipV: this.flipV(),
77
- isLoading: this.isLoading(),
217
+ isLoading: !this.loadedImages().has(this.src() || '') && !this.hasError(),
78
218
  hasError: this.hasError(),
219
+ currentIndex: this.currentIndex(),
220
+ total: this.images()?.length ?? 1,
79
221
  }), ...(ngDevMode ? [{ debugName: "state" }] : []));
80
222
  // Actions object for template
81
223
  actions = {
@@ -87,6 +229,9 @@ class ImagesPreviewComponent {
87
229
  flipVertical: () => this.flipVertical(),
88
230
  reset: () => this.reset(),
89
231
  close: () => this.close(),
232
+ next: () => this.next(),
233
+ prev: () => this.prev(),
234
+ jumpTo: (index) => this.jumpTo(index),
90
235
  };
91
236
  MIN_SCALE = 0.5;
92
237
  MAX_SCALE = 5;
@@ -102,18 +247,92 @@ class ImagesPreviewComponent {
102
247
  cachedConstraints = null;
103
248
  lastTimestamp = 0;
104
249
  rafId = null;
105
- FRICTION = 0.92; // Heavier feel
250
+ FRICTION = 0.95; // Super smooth glide
106
251
  VELOCITY_THRESHOLD = 0.01;
107
252
  MAX_VELOCITY = 3; // Cap speed to prevent teleporting
108
- transformStyle = computed(() => {
253
+ // Updated transform logic for slide buffer
254
+ getTransform(offset) {
255
+ const viewportWidth = window.innerWidth;
256
+ const spacing = 16; // Gap between images
257
+ const baseOffset = offset * (viewportWidth + spacing);
258
+ // Dynamic movement from touch/inertia
259
+ // Global translateX applies to the whole "track"
260
+ const x = this.translateX() + baseOffset;
261
+ // Y-axis drag (Pull-to-Close) usually only affects the active image visually,
262
+ // but moving the whole track is acceptable and simpler.
263
+ // Ideally, neighbors stay at Y=0.
264
+ const effectiveY = offset === 0 ? this.translateY() : 0;
265
+ const effectiveScale = offset === 0 ? this.scale() : 1;
266
+ const effectiveRotate = offset === 0 ? this.rotate() : 0;
267
+ const flipH = offset === 0 ? this.flipH() : false;
268
+ const flipV = offset === 0 ? this.flipV() : false;
269
+ const scaleX = flipH ? -1 : 1;
270
+ const scaleY = flipV ? -1 : 1;
271
+ // FLIP override
272
+ if (offset === 0) {
273
+ const flip = this.flipTransform();
274
+ if (flip)
275
+ return flip;
276
+ }
277
+ return `translate3d(${x}px, ${effectiveY}px, 0) scale(${effectiveScale}) rotate(${effectiveRotate}deg) scaleX(${scaleX}) scaleY(${scaleY})`;
278
+ }
279
+ // FLIP State
280
+ flipTransform = signal('', ...(ngDevMode ? [{ debugName: "flipTransform" }] : []));
281
+ // Helper to get current active image element
282
+ getCurrentImageElement() {
283
+ const buffer = this.activeBuffer();
284
+ const index = buffer.findIndex((item) => item.offset === 0);
285
+ if (index === -1)
286
+ return undefined;
287
+ return this.imgRefs()[index]?.nativeElement;
288
+ }
289
+ runFlipAnimation(opener) {
290
+ if (!isPlatformBrowser(this.platformId))
291
+ return;
292
+ const imgEl = this.getCurrentImageElement();
293
+ if (!imgEl)
294
+ return;
295
+ // 1. First: Final state is already rendered (centered, max fit)
296
+ const finalRect = imgEl.getBoundingClientRect();
297
+ // 2. Invert: Calculate scale and translate to match opener
298
+ const scaleX = opener.width / finalRect.width;
299
+ const scaleY = opener.height / finalRect.height;
300
+ // Note: we use the larger scale to crop?
301
+ // Or fit?
302
+ // Usually we want to fit.
303
+ // Let's matching fitting.
304
+ // Actually, most simple FLIP matches dimensions exactly.
305
+ // But aspect ratios might differ.
306
+ // Let's just match the rect exactly.
307
+ const deltaX = opener.left - finalRect.left + (opener.width - finalRect.width) / 2;
308
+ const deltaY = opener.top - finalRect.top + (opener.height - finalRect.height) / 2;
309
+ // Apply Invert Transform immediately (no transition)
310
+ imgEl.style.transition = 'none';
311
+ this.flipTransform.set(`translate3d(${deltaX}px, ${deltaY}px, 0) scale(${scaleX}, ${scaleY})`);
312
+ // Force Reflow
313
+ imgEl.offsetHeight;
314
+ // 3. Play
315
+ requestAnimationFrame(() => {
316
+ // Enable transition
317
+ imgEl.style.transition = 'transform 300ms cubic-bezier(0.2, 0, 0.2, 1)';
318
+ // Remove override (animates to Final)
319
+ this.flipTransform.set('');
320
+ this.flipAnimDone = true;
321
+ // CLEANUP: Remove transition after animation wraps up so dragging/panning is instant
322
+ setTimeout(() => {
323
+ imgEl.style.transition = '';
324
+ }, 300);
325
+ });
326
+ }
327
+ overlayBackground = computed(() => {
328
+ const y = Math.abs(this.translateY());
109
329
  const scale = this.scale();
110
- const x = this.translateX();
111
- const y = this.translateY();
112
- const rotate = this.rotate();
113
- const scaleX = this.flipH() ? -1 : 1;
114
- const scaleY = this.flipV() ? -1 : 1;
115
- return `translate3d(${x}px, ${y}px, 0) scale(${scale}) rotate(${rotate}deg) scaleX(${scaleX}) scaleY(${scaleY})`;
116
- }, ...(ngDevMode ? [{ debugName: "transformStyle" }] : []));
330
+ if (scale === 1 && y > 0) {
331
+ const opacity = Math.max(0, 0.95 - y / 400); // Fade out as we drag down
332
+ return `rgba(0, 0, 0, ${opacity})`;
333
+ }
334
+ return 'var(--ng-img-background)';
335
+ }, ...(ngDevMode ? [{ debugName: "overlayBackground" }] : []));
117
336
  onEscape() {
118
337
  this.close();
119
338
  }
@@ -134,13 +353,19 @@ class ImagesPreviewComponent {
134
353
  this.translateY.set(nextY);
135
354
  return;
136
355
  }
137
- const img = this.imgRef()?.nativeElement;
356
+ const img = this.getCurrentImageElement();
138
357
  if (!img) {
139
358
  this.translateX.set(nextX);
140
359
  this.translateY.set(nextY);
141
360
  return;
142
361
  }
143
362
  const scale = this.scale();
363
+ // If scale is 1, we allow free movement for Pull-to-Close and Swipe Nav
364
+ if (scale === 1) {
365
+ this.translateX.set(nextX);
366
+ this.translateY.set(nextY);
367
+ return;
368
+ }
144
369
  const rotate = Math.abs(this.rotate() % 180);
145
370
  const isRotated = rotate === 90;
146
371
  const baseWidth = img.offsetWidth;
@@ -158,7 +383,7 @@ class ImagesPreviewComponent {
158
383
  this.translateY.set(clampedY);
159
384
  }
160
385
  onTouchMove(event) {
161
- if (!this.isDragging() && !this.isPinching)
386
+ if (!this.isDragging() && !this.isPinching())
162
387
  return;
163
388
  // Prevent default to stop page scrolling/zooming
164
389
  if (event.cancelable) {
@@ -166,7 +391,7 @@ class ImagesPreviewComponent {
166
391
  }
167
392
  const touches = event.touches;
168
393
  // One finger: Pan
169
- if (touches.length === 1 && this.isDragging() && !this.isPinching) {
394
+ if (touches.length === 1 && this.isDragging() && !this.isPinching()) {
170
395
  const now = Date.now();
171
396
  // Add point to history
172
397
  this.touchHistory.push({
@@ -190,11 +415,30 @@ class ImagesPreviewComponent {
190
415
  // Apply rubber banding if out of bounds
191
416
  // Use cached constraints to avoid reflows
192
417
  const constraints = this.cachedConstraints || this.getConstraints();
193
- if (nextX > constraints.maxX) {
194
- nextX = constraints.maxX + (nextX - constraints.maxX) * 0.5;
418
+ // At scale 1, we want linear drag for pull-to-close (Y) and swipe (X)
419
+ // But we can keep rubber banding if it feels good.
420
+ // For Pull-to-Close, standard is 1:1 or slightly resisted.
421
+ // Let's stick to the current logic which applies resistance if "out of bounds".
422
+ // Since at scale 1 bounds are 0, it will apply resistance immediately.
423
+ // We want easier pull.
424
+ if (this.scale() === 1) {
425
+ // Reduce resistance
426
+ nextY = currentY + deltaY;
427
+ nextX = currentX + deltaX;
195
428
  }
196
- else if (nextX < -constraints.maxX) {
197
- nextX = -constraints.maxX + (nextX + constraints.maxX) * 0.5;
429
+ else {
430
+ if (nextX > constraints.maxX) {
431
+ nextX = constraints.maxX + (nextX - constraints.maxX) * 0.5;
432
+ }
433
+ else if (nextX < -constraints.maxX) {
434
+ nextX = -constraints.maxX + (nextX + constraints.maxX) * 0.5;
435
+ }
436
+ if (nextY > constraints.maxY) {
437
+ nextY = constraints.maxY + (nextY - constraints.maxY) * 0.5;
438
+ }
439
+ else if (nextY < -constraints.maxY) {
440
+ nextY = -constraints.maxY + (nextY + constraints.maxY) * 0.5;
441
+ }
198
442
  }
199
443
  if (nextY > constraints.maxY) {
200
444
  nextY = constraints.maxY + (nextY - constraints.maxY) * 0.5;
@@ -208,15 +452,44 @@ class ImagesPreviewComponent {
208
452
  this.lastTouchY = touches[0].clientY;
209
453
  }
210
454
  // Two fingers: Pinch Zoom
211
- if (touches.length === 2) {
455
+ if (touches.length === 2 && this.initialPinchDistance > 0) {
212
456
  const distance = this.getDistance(touches);
213
- if (this.initialPinchDistance > 0) {
214
- const scaleFactor = distance / this.initialPinchDistance;
215
- const newScale = Math.min(Math.max(this.initialScale * scaleFactor, this.MIN_SCALE), this.MAX_SCALE);
216
- this.scale.set(newScale);
217
- // Re-clamp position after zoom
218
- this.clampPosition();
457
+ const scaleFactor = distance / this.initialPinchDistance;
458
+ // Calculate new scale with limits
459
+ const rawNewScale = this.initialScale * scaleFactor;
460
+ const newScale = Math.min(Math.max(rawNewScale, this.MIN_SCALE), this.MAX_SCALE);
461
+ // Calculate new translation to keep focal point fixed
462
+ const effectiveRatio = newScale / this.initialScale;
463
+ const currentCenter = this.getCenter(touches);
464
+ const cx = currentCenter.x - window.innerWidth / 2;
465
+ const cy = currentCenter.y - window.innerHeight / 2;
466
+ const sx = this.initialPinchCenter.x - window.innerWidth / 2;
467
+ const sy = this.initialPinchCenter.y - window.innerHeight / 2;
468
+ let newTx = cx - (sx - this.initialTranslateX) * effectiveRatio;
469
+ let newTy = cy - (sy - this.initialTranslateY) * effectiveRatio;
470
+ // --- Clamp Logic (Inline for atomicity) ---
471
+ const img = this.getCurrentImageElement();
472
+ if (img) {
473
+ const rotate = Math.abs(this.rotate() % 180);
474
+ const isRotated = rotate === 90;
475
+ const baseWidth = img.offsetWidth;
476
+ const baseHeight = img.offsetHeight;
477
+ const currentWidth = (isRotated ? baseHeight : baseWidth) * newScale;
478
+ const currentHeight = (isRotated ? baseWidth : baseHeight) * newScale;
479
+ const maxTx = Math.max(0, (currentWidth - window.innerWidth) / 2);
480
+ const maxTy = Math.max(0, (currentHeight - window.innerHeight) / 2);
481
+ if (currentWidth <= window.innerWidth)
482
+ newTx = 0;
483
+ else if (Math.abs(newTx) > maxTx)
484
+ newTx = Math.sign(newTx) * maxTx;
485
+ if (currentHeight <= window.innerHeight)
486
+ newTy = 0;
487
+ else if (Math.abs(newTy) > maxTy)
488
+ newTy = Math.sign(newTy) * maxTy;
219
489
  }
490
+ this.scale.set(newScale);
491
+ this.translateX.set(newTx);
492
+ this.translateY.set(newTy);
220
493
  }
221
494
  }
222
495
  getConstraints() {
@@ -226,7 +499,7 @@ class ImagesPreviewComponent {
226
499
  // The `cachedConstraints` property is used *outside* this method.
227
500
  if (!isPlatformBrowser(this.platformId))
228
501
  return { maxX: 0, maxY: 0 };
229
- const img = this.imgRef()?.nativeElement;
502
+ const img = this.getCurrentImageElement();
230
503
  if (!img)
231
504
  return { maxX: 0, maxY: 0 };
232
505
  const scale = this.scale();
@@ -246,7 +519,7 @@ class ImagesPreviewComponent {
246
519
  clampPosition() {
247
520
  if (!isPlatformBrowser(this.platformId))
248
521
  return;
249
- const img = this.imgRef()?.nativeElement;
522
+ const img = this.getCurrentImageElement();
250
523
  if (!img)
251
524
  return;
252
525
  const scale = this.scale();
@@ -283,6 +556,7 @@ class ImagesPreviewComponent {
283
556
  this.translateY.set(newY);
284
557
  }
285
558
  startInertia() {
559
+ this.isInertia.set(true); // Disable CSS transition
286
560
  let lastTime = Date.now();
287
561
  const step = () => {
288
562
  if (!this.isDragging() &&
@@ -292,7 +566,6 @@ class ImagesPreviewComponent {
292
566
  const dt = Math.min(now - lastTime, 64); // Cap dt to avoid huge jumps if lag
293
567
  lastTime = now;
294
568
  if (dt === 0) {
295
- // Skip if 0ms passed (can happen on fast screens)
296
569
  this.rafId = requestAnimationFrame(step);
297
570
  return;
298
571
  }
@@ -300,14 +573,12 @@ class ImagesPreviewComponent {
300
573
  this.velocityX = Math.max(-this.MAX_VELOCITY, Math.min(this.MAX_VELOCITY, this.velocityX));
301
574
  this.velocityY = Math.max(-this.MAX_VELOCITY, Math.min(this.MAX_VELOCITY, this.velocityY));
302
575
  // Time-based friction
303
- // Standard friction is 0.95 per 16ms frame
304
576
  const frictionFactor = Math.pow(this.FRICTION, dt / 16);
305
577
  this.velocityX *= frictionFactor;
306
578
  this.velocityY *= frictionFactor;
307
579
  let nextX = this.translateX() + this.velocityX * dt;
308
580
  let nextY = this.translateY() + this.velocityY * dt;
309
581
  // Check bounds during inertia
310
- // Hard stop/bounce logic can be improved here if needed
311
582
  const constraints = this.cachedConstraints || this.getConstraints();
312
583
  if (nextX > constraints.maxX) {
313
584
  nextX = constraints.maxX;
@@ -331,14 +602,12 @@ class ImagesPreviewComponent {
331
602
  }
332
603
  else {
333
604
  this.stopInertia();
334
- // Clear cache when movement stops
335
- // The instruction says to clear it in onTouchEnd or reset/scale changes.
336
- // So, no need to clear it here.
337
605
  }
338
606
  };
339
607
  this.rafId = requestAnimationFrame(step);
340
608
  }
341
609
  stopInertia() {
610
+ this.isInertia.set(false); // Re-enable CSS transition
342
611
  if (this.rafId) {
343
612
  cancelAnimationFrame(this.rafId);
344
613
  this.rafId = null;
@@ -380,6 +649,27 @@ class ImagesPreviewComponent {
380
649
  event.stopPropagation();
381
650
  }
382
651
  onMouseUp() {
652
+ if (this.isDragging()) {
653
+ // Check Pull to Close for Mouse
654
+ if (this.scale() === 1) {
655
+ const y = this.translateY();
656
+ if (Math.abs(y) > 100) {
657
+ this.close();
658
+ return;
659
+ }
660
+ // Reset position if not closed
661
+ if (y !== 0) {
662
+ this.translateY.set(0);
663
+ }
664
+ const x = this.translateX();
665
+ if (x !== 0) {
666
+ this.translateX.set(0);
667
+ }
668
+ }
669
+ else {
670
+ this.snapBack();
671
+ }
672
+ }
383
673
  this.isDragging.set(false);
384
674
  }
385
675
  onTouchEnd(event) {
@@ -405,42 +695,55 @@ class ImagesPreviewComponent {
405
695
  this.velocityY = 0;
406
696
  }
407
697
  this.isDragging.set(false);
408
- this.isPinching = false;
698
+ this.isPinching.set(false);
409
699
  this.initialPinchDistance = 0;
410
700
  // Swipe Navigation (at 1x scale)
411
701
  if (this.scale() === 1) {
412
702
  const x = this.translateX();
413
- const threshold = 50; // px
703
+ const y = this.translateY();
704
+ // Pull to Close
705
+ if (Math.abs(y) > 100) {
706
+ this.close();
707
+ return;
708
+ }
709
+ const threshold = window.innerWidth * 0.25;
710
+ const images = this.images();
711
+ const total = images ? images.length : 0;
712
+ const index = this.currentIndex();
414
713
  if (x < -threshold) {
415
- this.next();
714
+ // NEXT (Slide Left)
715
+ if (index < total - 1) {
716
+ this.animateSlide(-1);
717
+ }
718
+ else {
719
+ this.translateX.set(0);
720
+ this.translateY.set(0);
721
+ }
416
722
  return;
417
723
  }
418
724
  else if (x > threshold) {
419
- this.prev();
725
+ // PREV (Slide Right)
726
+ if (index > 0) {
727
+ this.animateSlide(1);
728
+ }
729
+ else {
730
+ this.translateX.set(0);
731
+ this.translateY.set(0);
732
+ }
420
733
  return;
421
734
  }
422
- }
423
- // Check bounds
424
- const constraints = this.cachedConstraints || this.getConstraints();
425
- const x = this.translateX();
426
- const y = this.translateY();
427
- const outOfBounds = x > constraints.maxX ||
428
- x < -constraints.maxX ||
429
- y > constraints.maxY ||
430
- y < -constraints.maxY;
431
- if (outOfBounds) {
432
- this.snapBack();
433
- this.velocityX = 0;
434
- this.velocityY = 0;
435
- this.cachedConstraints = null;
735
+ // Snap back
736
+ this.translateX.set(0);
737
+ this.translateY.set(0);
436
738
  }
437
739
  else {
438
740
  this.startInertia();
439
741
  }
742
+ return;
440
743
  }
441
- else if (touches.length === 1 && this.isPinching) {
744
+ else if (touches.length === 1 && this.isPinching()) {
442
745
  // Transition from pinch to pan
443
- this.isPinching = false;
746
+ this.isPinching.set(false);
444
747
  this.isDragging.set(true);
445
748
  this.lastTouchX = touches[0].clientX;
446
749
  this.lastTouchY = touches[0].clientY;
@@ -454,18 +757,32 @@ class ImagesPreviewComponent {
454
757
  }
455
758
  lastTouchX = 0;
456
759
  lastTouchY = 0;
760
+ initialPinchCenter = { x: 0, y: 0 };
761
+ initialTranslateX = 0;
762
+ initialTranslateY = 0;
457
763
  getDistance(touches) {
458
764
  return Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY);
459
765
  }
766
+ getCenter(touches) {
767
+ return {
768
+ x: (touches[0].clientX + touches[1].clientX) / 2,
769
+ y: (touches[0].clientY + touches[1].clientY) / 2,
770
+ };
771
+ }
460
772
  close() {
461
773
  this.closeCallback();
462
774
  }
463
- onImageLoad() {
464
- this.isLoading.set(false);
775
+ onImageLoad(src) {
776
+ this.loadedImages.update(set => {
777
+ const newSet = new Set(set);
778
+ newSet.add(src);
779
+ return newSet;
780
+ });
465
781
  this.hasError.set(false);
466
782
  }
467
- onImageError() {
468
- this.isLoading.set(false);
783
+ onImageError(src) {
784
+ // this.loadedImages.update... (maybe not mark as loaded? or separate error tracking)
785
+ // For now, keep error simple
469
786
  this.hasError.set(true);
470
787
  }
471
788
  // Zoom
@@ -521,14 +838,45 @@ class ImagesPreviewComponent {
521
838
  this.currentIndex.update((i) => i - 1);
522
839
  }
523
840
  }
841
+ jumpTo(index) {
842
+ this.currentIndex.set(index);
843
+ this.reset();
844
+ }
845
+ // Slide Animation
846
+ animateSlide(direction) {
847
+ const spacing = 16;
848
+ const width = window.innerWidth + spacing;
849
+ // direction -1 (Next) -> move to -width
850
+ // direction 1 (Prev) -> move to width
851
+ // Wait. If I swipe Left (Next), x is negative. Target should be -width.
852
+ // If I swipe Right (Prev), x is positive. Target should be width.
853
+ // So target = direction * width? No.
854
+ // If direction is "Next" (index + 1), I want to slide to the Left (-X).
855
+ // Let's pass the sign of movement explicitly.
856
+ const target = direction === -1 ? -width : width;
857
+ this.translateX.set(target);
858
+ // Wait for CSS transition (0.3s)
859
+ setTimeout(() => {
860
+ // Update Index (silent swap)
861
+ this.isInertia.set(true); // Disable transition
862
+ if (direction === -1)
863
+ this.next();
864
+ else
865
+ this.prev();
866
+ this.translateX.set(0);
867
+ // Re-enable transition
868
+ setTimeout(() => {
869
+ this.isInertia.set(false);
870
+ }, 50);
871
+ }, 300);
872
+ }
524
873
  // Mouse Interaction
525
874
  onMouseDown(event) {
526
- if (this.scale() <= 1 && !this.isDragging())
527
- return; // Can drag only if zoomed? Or always? Default: only zoomed?
528
- // User expectation for preview: maybe panning always allowed? or only when zoomed?
529
- // Best practice: if image fits, no pan. If zoomed, pan.
530
- // I'll allow pan if scale > 1
531
- if (this.scale() <= 1)
875
+ // Allow dragging even at scale 1 for Pull-to-Close and Swipe-like nav (if intended)
876
+ if (this.isDragging())
877
+ return;
878
+ // Only trigger on Left Click
879
+ if (event.button !== 0)
532
880
  return;
533
881
  this.isDragging.set(true);
534
882
  this.startX = event.clientX;
@@ -540,36 +888,38 @@ class ImagesPreviewComponent {
540
888
  // Touch Interaction (bound in template)
541
889
  onTouchStart(event) {
542
890
  this.stopInertia(); // Cancel any ongoing movement
891
+ // if (event.cancelable) event.preventDefault(); // Removed to restore Tap-to-Close (click events)
892
+ // touch-action: none in CSS handles scroll prevention.
543
893
  const touches = event.touches;
544
894
  if (touches.length === 1) {
545
- // Single touch: Pan. Only if touching the image directly.
546
- // We need to check if the target is the image element.
547
- const imgElement = this.imgRef()?.nativeElement;
548
- if (imgElement && event.target === imgElement) {
549
- this.isDragging.set(true);
550
- this.lastTouchX = touches[0].clientX;
551
- this.lastTouchY = touches[0].clientY;
552
- this.lastTimestamp = Date.now(); // Keep for scroll decay reference if needed
553
- // Initialize physics state
554
- this.velocityX = 0;
555
- this.velocityY = 0;
556
- this.touchHistory = [
557
- {
558
- x: touches[0].clientX,
559
- y: touches[0].clientY,
560
- time: Date.now(),
561
- },
562
- ];
563
- // Cache layout to prevent thrashing
564
- this.cachedConstraints = this.getConstraints();
565
- }
895
+ // Single touch: Pan.
896
+ // Allow dragging from anywhere in the container (better UX for Pull-to-Close)
897
+ this.isDragging.set(true);
898
+ this.lastTouchX = touches[0].clientX;
899
+ this.lastTouchY = touches[0].clientY;
900
+ this.lastTimestamp = Date.now();
901
+ // Initialize physics state
902
+ this.velocityX = 0;
903
+ this.velocityY = 0;
904
+ this.touchHistory = [
905
+ {
906
+ x: touches[0].clientX,
907
+ y: touches[0].clientY,
908
+ time: Date.now(),
909
+ },
910
+ ];
911
+ // Cache layout to prevent thrashing
912
+ this.cachedConstraints = this.getConstraints();
566
913
  }
567
914
  else if (touches.length === 2) {
568
915
  // Two fingers: Pinch
569
- this.isPinching = true;
916
+ this.isPinching.set(true);
570
917
  this.isDragging.set(false); // Stop panning
571
918
  this.initialPinchDistance = this.getDistance(touches);
572
919
  this.initialScale = this.scale();
920
+ this.initialPinchCenter = this.getCenter(touches);
921
+ this.initialTranslateX = this.translateX();
922
+ this.initialTranslateY = this.translateY();
573
923
  // Clear caching on pinch (scale changes will invalidate limits)
574
924
  this.cachedConstraints = null;
575
925
  // Prevent default to avoid browser zoom
@@ -578,7 +928,7 @@ class ImagesPreviewComponent {
578
928
  }
579
929
  }
580
930
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.7", ngImport: i0, type: ImagesPreviewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
581
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.0.7", type: ImagesPreviewComponent, isStandalone: true, selector: "ng-images-preview", inputs: { src: { classPropertyName: "src", publicName: "src", isSignal: true, isRequired: true, transformFunction: null }, images: { classPropertyName: "images", publicName: "images", isSignal: true, isRequired: false, transformFunction: null }, initialIndex: { classPropertyName: "initialIndex", publicName: "initialIndex", isSignal: true, isRequired: false, transformFunction: null }, customTemplate: { classPropertyName: "customTemplate", publicName: "customTemplate", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "document:mouseup": "onMouseUp()", "document:touchmove": "onTouchMove($event)", "document:touchend": "onTouchEnd($event)", "document:keydown.arrowleft": "prev()", "document:keydown.arrowright": "next()", "document:keydown.escape": "onEscape()", "document:mousemove": "onMouseMove($event)" } }, viewQueries: [{ propertyName: "imgRef", first: true, predicate: ["imgRef"], descendants: true, isSignal: true }], ngImport: i0, template: `
931
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.0.7", type: ImagesPreviewComponent, isStandalone: true, selector: "ng-images-preview", inputs: { src: { classPropertyName: "src", publicName: "src", isSignal: true, isRequired: true, transformFunction: null }, images: { classPropertyName: "images", publicName: "images", isSignal: true, isRequired: false, transformFunction: null }, srcset: { classPropertyName: "srcset", publicName: "srcset", isSignal: true, isRequired: false, transformFunction: null }, srcsets: { classPropertyName: "srcsets", publicName: "srcsets", isSignal: true, isRequired: false, transformFunction: null }, initialIndex: { classPropertyName: "initialIndex", publicName: "initialIndex", isSignal: true, isRequired: false, transformFunction: null }, openerRect: { classPropertyName: "openerRect", publicName: "openerRect", isSignal: true, isRequired: false, transformFunction: null }, customTemplate: { classPropertyName: "customTemplate", publicName: "customTemplate", isSignal: true, isRequired: false, transformFunction: null }, toolbarConfig: { classPropertyName: "toolbarConfig", publicName: "toolbarConfig", isSignal: true, isRequired: false, transformFunction: null }, showThumbnails: { classPropertyName: "showThumbnails", publicName: "showThumbnails", isSignal: true, isRequired: false, transformFunction: null }, showToolbar: { classPropertyName: "showToolbar", publicName: "showToolbar", isSignal: true, isRequired: false, transformFunction: null }, toolbarExtensions: { classPropertyName: "toolbarExtensions", publicName: "toolbarExtensions", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "document:mouseup": "onMouseUp()", "document:touchmove": "onTouchMove($event)", "document:touchend": "onTouchEnd($event)", "document:keydown.arrowleft": "prev()", "document:keydown.arrowright": "next()", "document:keydown.escape": "onEscape()", "document:mousemove": "onMouseMove($event)" } }, viewQueries: [{ propertyName: "imgRefs", predicate: ["imgRef"], descendants: true, isSignal: true }, { propertyName: "thumbRefs", predicate: ["thumbRef"], descendants: true, isSignal: true }], ngImport: i0, template: `
582
932
  <div
583
933
  class="overlay"
584
934
  [@fadeInOut]
@@ -587,6 +937,7 @@ class ImagesPreviewComponent {
587
937
  tabindex="0"
588
938
  role="button"
589
939
  aria-label="Close preview overlay"
940
+ [style.background-color]="overlayBackground()"
590
941
  >
591
942
  <!-- Custom Template Support -->
592
943
  @if (customTemplate(); as template) {
@@ -595,7 +946,7 @@ class ImagesPreviewComponent {
595
946
  ></ng-container>
596
947
  } @else {
597
948
  <!-- Loading -->
598
- @if (isLoading()) {
949
+ @if (!loadedImages().has(src()) && !hasError()) {
599
950
  <div class="loader">Loading...</div>
600
951
  }
601
952
 
@@ -611,27 +962,32 @@ class ImagesPreviewComponent {
611
962
  (touchstart)="onTouchStart($event)"
612
963
  tabindex="-1"
613
964
  >
614
- <img
615
- #imgRef
616
- [src]="activeSrc()"
617
- [class.opacity-0]="isLoading() || hasError()"
618
- class="preview-image"
619
- [class.dragging]="isDragging()"
620
- [class.zoom-in]="scale() === 1"
621
- [class.zoom-out]="scale() > 1"
622
- [style.transform]="transformStyle()"
623
- (load)="onImageLoad()"
624
- (error)="onImageError()"
625
- (mousedown)="onMouseDown($event)"
626
- (click)="$event.stopPropagation()"
627
- draggable="false"
628
- alt="Preview"
629
- />
965
+ @for (item of activeBuffer(); track item.src) {
966
+ <img
967
+ #imgRef
968
+ [src]="item.src"
969
+ [srcset]="item.srcset || ''"
970
+ [class.opacity-0]="item.offset === 0 && (!loadedImages().has(item.src) || hasError())"
971
+ class="preview-image"
972
+ [class.dragging]="isDragging()"
973
+ [class.inertia]="isInertia()"
974
+ [class.pinching]="isPinching()"
975
+ [class.zoom-in]="scale() === 1"
976
+ [class.zoom-out]="scale() > 1"
977
+ [class.hidden]="item.offset !== 0 && scale() > 1"
978
+ [style.transform]="getTransform(item.offset)"
979
+ (load)="onImageLoad(item.src)"
980
+ (error)="onImageError(item.src)"
981
+ (mousedown)="onMouseDown($event)"
982
+ (click)="$event.stopPropagation()"
983
+ draggable="false"
984
+ alt="Preview"
985
+ />
986
+ }
630
987
  </div>
631
988
 
632
989
  <!-- Close Button -->
633
- <button class="close-btn" (click)="close()" aria-label="Close preview">
634
- <svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
990
+ <button class="close-btn" (click)="close()" aria-label="Close preview" [innerHTML]="icons.close">
635
991
  </button>
636
992
 
637
993
  <!-- Navigation -->
@@ -643,8 +999,8 @@ class ImagesPreviewComponent {
643
999
  (click)="prev(); $event.stopPropagation()"
644
1000
  (mousedown)="$event.stopPropagation()"
645
1001
  aria-label="Previous image"
1002
+ [innerHTML]="icons.prev"
646
1003
  >
647
- <svg viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
648
1004
  </button>
649
1005
  }
650
1006
  <!-- Check if not last -->
@@ -654,8 +1010,8 @@ class ImagesPreviewComponent {
654
1010
  (click)="next(); $event.stopPropagation()"
655
1011
  (mousedown)="$event.stopPropagation()"
656
1012
  aria-label="Next image"
1013
+ [innerHTML]="icons.next"
657
1014
  >
658
- <svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
659
1015
  </button>
660
1016
  }
661
1017
 
@@ -664,40 +1020,61 @@ class ImagesPreviewComponent {
664
1020
  }
665
1021
 
666
1022
  <!-- Toolbar -->
1023
+ <!-- Toolbar -->
1024
+ @if (showToolbar()) {
667
1025
  <div
668
1026
  class="toolbar"
669
1027
  (click)="$event.stopPropagation()"
670
1028
  (keydown)="onToolbarKey($event)"
671
1029
  tabindex="0"
672
1030
  >
673
- <!-- Flip H -->
674
- <button class="toolbar-btn" (click)="flipHorizontal()" aria-label="Flip Horizontal">
675
- <svg viewBox="0 0 24 24"><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8 20h2V1h-2v22zm8-6h2v-2h-2v2zM15 5h2V3h-2v2zm4 8h2v-2h-2v2zm0 8c1.1 0 2-.9 2-2h-2v2z"/></svg>
676
- </button>
677
- <!-- Flip V -->
678
- <button class="toolbar-btn" (click)="flipVertical()" aria-label="Flip Vertical">
679
- <svg viewBox="0 0 24 24"><path d="M7 21h2v-2H7v2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-12h2V7h-2v2zm-4 4h2v-4h-2v4zm-8-8h2V3H7v2zm8 0h2V3h-2v2zm-4 8h4v-4h-4v4zM3 15h2v-2H3v2zm12-4h2v-4H3v4h2v-2h10v2zM7 3v2h2V3H7zm8 20h2v-2h-2v2zm-4 0h2v-2h-2v2zm4-12h2v-2h-2v2zM3 19c0 1.1.9 2 2 2h2v-2H5v-2H3v2zm16-6h2v-2h-2v2zm0 4v2c0 1.1-.9 2-2 2h-2v-2h2v-2h2zM5 3c-1.1 0-2 .9-2 2h2V3z"/></svg>
680
- </button>
681
- <!-- Rotate Left -->
682
- <button class="toolbar-btn" (click)="rotateLeft()" aria-label="Rotate Left">
683
- <svg viewBox="0 0 24 24"><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
684
- </button>
685
- <!-- Rotate Right -->
686
- <button class="toolbar-btn" (click)="rotateRight()" aria-label="Rotate Right">
687
- <svg viewBox="0 0 24 24"><path d="M15.55 5.55L11 1v3.07C7.06 4.56 4 7.92 4 12s3.05 7.44 7 7.93v-2.02c-2.84-.48-5-2.94-5-5.91s2.16-5.43 5-5.91V10l4.55-4.45zM19.93 11c-.17-1.39-.72-2.73-1.62-3.89l-1.42 1.42c.54.75.88 1.6 1.02 2.47h2.02zM13 17.9v2.02c1.39-.17 2.74-.71 3.9-1.61l-1.44-1.44c-.75.54-1.59.89-2.46 1.03zm3.89-2.42l1.42 1.41c.9-1.16 1.45-2.5 1.62-3.89h-2.02c-.14.87-.48 1.72-1.02 2.48z"/></svg>
688
- </button>
689
- <!-- Zoom Out -->
690
- <button class="toolbar-btn" (click)="zoomOut()" aria-label="Zoom Out">
691
- <svg viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>
692
- </button>
693
- <!-- Zoom In -->
694
- <button class="toolbar-btn" (click)="zoomIn()" aria-label="Zoom In">
695
- <svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
696
- </button>
1031
+ <!-- Flip -->
1032
+ @if (toolbarConfig().showFlip) {
1033
+ <button class="toolbar-btn" (click)="flipHorizontal()" aria-label="Flip Horizontal" [innerHTML]="icons.flipH">
1034
+ </button>
1035
+ <button class="toolbar-btn" (click)="flipVertical()" aria-label="Flip Vertical" [innerHTML]="icons.flipV">
1036
+ </button>
1037
+ }
1038
+
1039
+ <!-- Extensions (Custom Buttons) -->
1040
+ @if (toolbarExtensions()) {
1041
+ <ng-container *ngTemplateOutlet="toolbarExtensions(); context: { $implicit: images()![currentIndex()] }"></ng-container>
1042
+ }
1043
+ <!-- Rotate -->
1044
+ @if (toolbarConfig().showRotate) {
1045
+ <button class="toolbar-btn" (click)="rotateLeft()" aria-label="Rotate Left" [innerHTML]="icons.rotateLeft">
1046
+ </button>
1047
+ <button class="toolbar-btn" (click)="rotateRight()" aria-label="Rotate Right" [innerHTML]="icons.rotateRight">
1048
+ </button>
1049
+ }
1050
+ <!-- Zoom -->
1051
+ @if (toolbarConfig().showZoom) {
1052
+ <button class="toolbar-btn" (click)="zoomOut()" aria-label="Zoom Out" [innerHTML]="icons.zoomOut">
1053
+ </button>
1054
+ <button class="toolbar-btn" (click)="zoomIn()" aria-label="Zoom In" [innerHTML]="icons.zoomIn">
1055
+ </button>
1056
+ }
1057
+ </div>
1058
+ }
1059
+ }
1060
+
1061
+ <!-- Thumbnails -->
1062
+ @if (images() && images()!.length > 1 && showThumbnails()) {
1063
+ <div class="thumbnail-strip" (click)="$event.stopPropagation()">
1064
+ @for (img of images(); track img; let i = $index) {
1065
+ <div
1066
+ #thumbRef
1067
+ class="thumbnail-item"
1068
+ [class.active]="i === currentIndex()"
1069
+ (click)="jumpTo(i)"
1070
+ >
1071
+ <img [src]="img" loading="lazy" alt="Thumbnail">
1072
+ </div>
1073
+ }
697
1074
  </div>
698
1075
  }
699
1076
  </div>
700
- `, isInline: true, styles: [":host{display:block}.overlay{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background-color:#000000f2;overflow:hidden;-webkit-user-select:none;user-select:none;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}.loader,.error{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fffc;font-size:16px}.image-container{position:absolute;inset:0;width:100%;height:100%;margin:0;padding:0;box-sizing:border-box;display:flex;align-items:center;justify-content:center;touch-action:none}.preview-image{width:auto;height:auto;max-width:100%;max-height:100%;margin:auto;object-fit:contain;pointer-events:auto;will-change:transform;transform-origin:center center;transition:transform .1s ease-out;touch-action:none}.preview-image.dragging{cursor:move;transition:none}.preview-image.zoom-in{cursor:zoom-in}.preview-image.zoom-out{cursor:grab}.toolbar{position:absolute;bottom:30px;left:50%;transform:translate(-50%);display:flex;gap:16px;background-color:#ffffff1a;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);padding:10px 20px;border-radius:24px;pointer-events:auto;z-index:60}.toolbar-btn{background:none;border:none;color:#fff;cursor:pointer;padding:8px;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:background-color .2s}.toolbar-btn:hover{background-color:#fff3}.toolbar-btn:focus-visible{outline:2px solid white;outline-offset:2px}.toolbar-btn svg{width:24px;height:24px;fill:currentColor}.close-btn{position:absolute;top:20px;right:20px;z-index:60;background:none;border:none;color:#fff;cursor:pointer;padding:8px;border-radius:50%;background-color:#00000080;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.close-btn svg{width:24px;height:24px;fill:currentColor}.close-btn:hover{background:#fff3;transform:rotate(90deg)}.nav-btn{position:absolute;top:50%;transform:translateY(-50%);width:48px;height:48px;border-radius:50%;background:#ffffff1a;border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;z-index:10}.nav-btn:hover{background:#fff3}.nav-btn.prev{left:20px}.nav-btn.next{right:20px}.nav-btn svg{width:32px;height:32px;fill:currentColor;filter:drop-shadow(0 2px 4px rgba(0,0,0,.5))}.counter{position:absolute;top:20px;left:50%;transform:translate(-50%);color:#fffc;font-size:14px;background:#0000004d;padding:4px 12px;border-radius:12px;pointer-events:none;z-index:10}.hidden{display:none}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], animations: [
1077
+ `, isInline: true, styles: [":host{display:block;--ng-img-background: rgba(0, 0, 0, .95);--ng-img-text-color: rgba(255, 255, 255, .8);--ng-img-z-index: 50;--ng-img-toolbar-bg: rgba(255, 255, 255, .1);--ng-img-toolbar-hover: rgba(255, 255, 255, .2);--ng-img-gap: 16px;--ng-img-item-bg: rgba(0, 0, 0, .3);--ng-img-thumb-strip-bg: rgba(0, 0, 0, .4);--ng-img-thumb-width: 60px;--ng-img-thumb-height: 40px;--ng-img-thumb-gap: 8px;--ng-img-thumb-border-radius: 6px;--ng-img-thumb-active-border: white}.overlay{position:fixed;inset:0;z-index:var(--ng-img-z-index);display:flex;align-items:center;justify-content:center;background-color:var(--ng-img-background);overflow:hidden;-webkit-user-select:none;user-select:none;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;touch-action:none}.loader,.error{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--ng-img-text-color);font-size:16px}.image-container{position:absolute;inset:0;width:100%;height:100%;margin:0;padding:0;box-sizing:border-box;display:flex;align-items:center;justify-content:center;touch-action:none}.preview-image{position:absolute;top:0;left:0;width:100%;height:100%;margin:auto;object-fit:contain;pointer-events:auto;will-change:transform;transform-origin:center center;transition:transform .3s cubic-bezier(.2,0,.2,1);touch-action:none}.preview-image.dragging,.preview-image.inertia,.preview-image.pinching{cursor:move;transition:none}.preview-image.zoom-in{cursor:zoom-in}.preview-image.zoom-out{cursor:grab}.toolbar{position:absolute;bottom:30px;left:50%;transform:translate(-50%);display:flex;gap:var(--ng-img-gap);background-color:var(--ng-img-toolbar-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);padding:10px 20px;border-radius:24px;pointer-events:auto;z-index:calc(var(--ng-img-z-index) + 10)}.toolbar-btn{background:none;border:none;color:#fff;cursor:pointer;padding:8px;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:background-color .2s}.toolbar-btn:hover{background-color:var(--ng-img-toolbar-hover)}.toolbar-btn:focus-visible{outline:2px solid white;outline-offset:2px}.toolbar-btn ::ng-deep svg{width:24px;height:24px;fill:currentColor}.close-btn{position:absolute;top:20px;right:20px;z-index:calc(var(--ng-img-z-index) + 10);background:none;border:none;color:#fff;cursor:pointer;padding:8px;border-radius:50%;background-color:#00000080;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.close-btn ::ng-deep svg{width:24px;height:24px;fill:currentColor}.close-btn:hover{background:var(--ng-img-toolbar-hover);transform:rotate(90deg)}.nav-btn{position:absolute;top:50%;transform:translateY(-50%);width:48px;height:48px;border-radius:50%;background:var(--ng-img-toolbar-bg);border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;z-index:10}.nav-btn:hover{background:var(--ng-img-toolbar-hover)}.nav-btn.prev{left:20px}.nav-btn.next{right:20px}.nav-btn ::ng-deep svg{width:32px;height:32px;fill:currentColor;filter:drop-shadow(0 2px 4px rgba(0,0,0,.5))}.counter{position:absolute;top:20px;left:50%;transform:translate(-50%);color:var(--ng-img-text-color);font-size:14px;background:var(--ng-img-item-bg);padding:4px 12px;border-radius:12px;pointer-events:none;z-index:10}.hidden{display:none}.thumbnail-strip{position:absolute;bottom:100px;left:50%;transform:translate(-50%);display:inline-block;white-space:nowrap;max-width:90%;width:auto;overflow-x:auto;overflow-y:hidden;padding:8px;background:var(--ng-img-thumb-strip-bg);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border-radius:12px;scroll-behavior:smooth;scrollbar-width:none;z-index:55;font-size:0}.thumbnail-strip::-webkit-scrollbar{display:none}.thumbnail-item{display:inline-block;vertical-align:middle;width:var(--ng-img-thumb-width);height:var(--ng-img-thumb-height);margin-right:var(--ng-img-thumb-gap);border-radius:var(--ng-img-thumb-border-radius);overflow:hidden;cursor:pointer;opacity:.6;transition:all .2s;border:2px solid transparent;box-sizing:border-box}.thumbnail-item:last-child{margin-right:0}.thumbnail-item:hover{opacity:.8}.thumbnail-item.active{opacity:1;border-color:var(--ng-img-thumb-active-border);transform:scale(1.1)}.thumbnail-item img{width:100%;height:100%;object-fit:cover}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], animations: [
701
1078
  trigger('fadeInOut', [
702
1079
  transition(':enter', [
703
1080
  style({ opacity: 0 }),
@@ -718,6 +1095,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
718
1095
  tabindex="0"
719
1096
  role="button"
720
1097
  aria-label="Close preview overlay"
1098
+ [style.background-color]="overlayBackground()"
721
1099
  >
722
1100
  <!-- Custom Template Support -->
723
1101
  @if (customTemplate(); as template) {
@@ -726,7 +1104,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
726
1104
  ></ng-container>
727
1105
  } @else {
728
1106
  <!-- Loading -->
729
- @if (isLoading()) {
1107
+ @if (!loadedImages().has(src()) && !hasError()) {
730
1108
  <div class="loader">Loading...</div>
731
1109
  }
732
1110
 
@@ -742,27 +1120,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
742
1120
  (touchstart)="onTouchStart($event)"
743
1121
  tabindex="-1"
744
1122
  >
745
- <img
746
- #imgRef
747
- [src]="activeSrc()"
748
- [class.opacity-0]="isLoading() || hasError()"
749
- class="preview-image"
750
- [class.dragging]="isDragging()"
751
- [class.zoom-in]="scale() === 1"
752
- [class.zoom-out]="scale() > 1"
753
- [style.transform]="transformStyle()"
754
- (load)="onImageLoad()"
755
- (error)="onImageError()"
756
- (mousedown)="onMouseDown($event)"
757
- (click)="$event.stopPropagation()"
758
- draggable="false"
759
- alt="Preview"
760
- />
1123
+ @for (item of activeBuffer(); track item.src) {
1124
+ <img
1125
+ #imgRef
1126
+ [src]="item.src"
1127
+ [srcset]="item.srcset || ''"
1128
+ [class.opacity-0]="item.offset === 0 && (!loadedImages().has(item.src) || hasError())"
1129
+ class="preview-image"
1130
+ [class.dragging]="isDragging()"
1131
+ [class.inertia]="isInertia()"
1132
+ [class.pinching]="isPinching()"
1133
+ [class.zoom-in]="scale() === 1"
1134
+ [class.zoom-out]="scale() > 1"
1135
+ [class.hidden]="item.offset !== 0 && scale() > 1"
1136
+ [style.transform]="getTransform(item.offset)"
1137
+ (load)="onImageLoad(item.src)"
1138
+ (error)="onImageError(item.src)"
1139
+ (mousedown)="onMouseDown($event)"
1140
+ (click)="$event.stopPropagation()"
1141
+ draggable="false"
1142
+ alt="Preview"
1143
+ />
1144
+ }
761
1145
  </div>
762
1146
 
763
1147
  <!-- Close Button -->
764
- <button class="close-btn" (click)="close()" aria-label="Close preview">
765
- <svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
1148
+ <button class="close-btn" (click)="close()" aria-label="Close preview" [innerHTML]="icons.close">
766
1149
  </button>
767
1150
 
768
1151
  <!-- Navigation -->
@@ -774,8 +1157,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
774
1157
  (click)="prev(); $event.stopPropagation()"
775
1158
  (mousedown)="$event.stopPropagation()"
776
1159
  aria-label="Previous image"
1160
+ [innerHTML]="icons.prev"
777
1161
  >
778
- <svg viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
779
1162
  </button>
780
1163
  }
781
1164
  <!-- Check if not last -->
@@ -785,8 +1168,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
785
1168
  (click)="next(); $event.stopPropagation()"
786
1169
  (mousedown)="$event.stopPropagation()"
787
1170
  aria-label="Next image"
1171
+ [innerHTML]="icons.next"
788
1172
  >
789
- <svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
790
1173
  </button>
791
1174
  }
792
1175
 
@@ -795,36 +1178,57 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
795
1178
  }
796
1179
 
797
1180
  <!-- Toolbar -->
1181
+ <!-- Toolbar -->
1182
+ @if (showToolbar()) {
798
1183
  <div
799
1184
  class="toolbar"
800
1185
  (click)="$event.stopPropagation()"
801
1186
  (keydown)="onToolbarKey($event)"
802
1187
  tabindex="0"
803
1188
  >
804
- <!-- Flip H -->
805
- <button class="toolbar-btn" (click)="flipHorizontal()" aria-label="Flip Horizontal">
806
- <svg viewBox="0 0 24 24"><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8 20h2V1h-2v22zm8-6h2v-2h-2v2zM15 5h2V3h-2v2zm4 8h2v-2h-2v2zm0 8c1.1 0 2-.9 2-2h-2v2z"/></svg>
807
- </button>
808
- <!-- Flip V -->
809
- <button class="toolbar-btn" (click)="flipVertical()" aria-label="Flip Vertical">
810
- <svg viewBox="0 0 24 24"><path d="M7 21h2v-2H7v2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-12h2V7h-2v2zm-4 4h2v-4h-2v4zm-8-8h2V3H7v2zm8 0h2V3h-2v2zm-4 8h4v-4h-4v4zM3 15h2v-2H3v2zm12-4h2v-4H3v4h2v-2h10v2zM7 3v2h2V3H7zm8 20h2v-2h-2v2zm-4 0h2v-2h-2v2zm4-12h2v-2h-2v2zM3 19c0 1.1.9 2 2 2h2v-2H5v-2H3v2zm16-6h2v-2h-2v2zm0 4v2c0 1.1-.9 2-2 2h-2v-2h2v-2h2zM5 3c-1.1 0-2 .9-2 2h2V3z"/></svg>
811
- </button>
812
- <!-- Rotate Left -->
813
- <button class="toolbar-btn" (click)="rotateLeft()" aria-label="Rotate Left">
814
- <svg viewBox="0 0 24 24"><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
815
- </button>
816
- <!-- Rotate Right -->
817
- <button class="toolbar-btn" (click)="rotateRight()" aria-label="Rotate Right">
818
- <svg viewBox="0 0 24 24"><path d="M15.55 5.55L11 1v3.07C7.06 4.56 4 7.92 4 12s3.05 7.44 7 7.93v-2.02c-2.84-.48-5-2.94-5-5.91s2.16-5.43 5-5.91V10l4.55-4.45zM19.93 11c-.17-1.39-.72-2.73-1.62-3.89l-1.42 1.42c.54.75.88 1.6 1.02 2.47h2.02zM13 17.9v2.02c1.39-.17 2.74-.71 3.9-1.61l-1.44-1.44c-.75.54-1.59.89-2.46 1.03zm3.89-2.42l1.42 1.41c.9-1.16 1.45-2.5 1.62-3.89h-2.02c-.14.87-.48 1.72-1.02 2.48z"/></svg>
819
- </button>
820
- <!-- Zoom Out -->
821
- <button class="toolbar-btn" (click)="zoomOut()" aria-label="Zoom Out">
822
- <svg viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>
823
- </button>
824
- <!-- Zoom In -->
825
- <button class="toolbar-btn" (click)="zoomIn()" aria-label="Zoom In">
826
- <svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
827
- </button>
1189
+ <!-- Flip -->
1190
+ @if (toolbarConfig().showFlip) {
1191
+ <button class="toolbar-btn" (click)="flipHorizontal()" aria-label="Flip Horizontal" [innerHTML]="icons.flipH">
1192
+ </button>
1193
+ <button class="toolbar-btn" (click)="flipVertical()" aria-label="Flip Vertical" [innerHTML]="icons.flipV">
1194
+ </button>
1195
+ }
1196
+
1197
+ <!-- Extensions (Custom Buttons) -->
1198
+ @if (toolbarExtensions()) {
1199
+ <ng-container *ngTemplateOutlet="toolbarExtensions(); context: { $implicit: images()![currentIndex()] }"></ng-container>
1200
+ }
1201
+ <!-- Rotate -->
1202
+ @if (toolbarConfig().showRotate) {
1203
+ <button class="toolbar-btn" (click)="rotateLeft()" aria-label="Rotate Left" [innerHTML]="icons.rotateLeft">
1204
+ </button>
1205
+ <button class="toolbar-btn" (click)="rotateRight()" aria-label="Rotate Right" [innerHTML]="icons.rotateRight">
1206
+ </button>
1207
+ }
1208
+ <!-- Zoom -->
1209
+ @if (toolbarConfig().showZoom) {
1210
+ <button class="toolbar-btn" (click)="zoomOut()" aria-label="Zoom Out" [innerHTML]="icons.zoomOut">
1211
+ </button>
1212
+ <button class="toolbar-btn" (click)="zoomIn()" aria-label="Zoom In" [innerHTML]="icons.zoomIn">
1213
+ </button>
1214
+ }
1215
+ </div>
1216
+ }
1217
+ }
1218
+
1219
+ <!-- Thumbnails -->
1220
+ @if (images() && images()!.length > 1 && showThumbnails()) {
1221
+ <div class="thumbnail-strip" (click)="$event.stopPropagation()">
1222
+ @for (img of images(); track img; let i = $index) {
1223
+ <div
1224
+ #thumbRef
1225
+ class="thumbnail-item"
1226
+ [class.active]="i === currentIndex()"
1227
+ (click)="jumpTo(i)"
1228
+ >
1229
+ <img [src]="img" loading="lazy" alt="Thumbnail">
1230
+ </div>
1231
+ }
828
1232
  </div>
829
1233
  }
830
1234
  </div>
@@ -843,8 +1247,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
843
1247
  '(document:keydown.arrowleft)': 'prev()',
844
1248
  '(document:keydown.arrowright)': 'next()',
845
1249
  '(document:keydown.escape)': 'close()',
846
- }, styles: [":host{display:block}.overlay{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background-color:#000000f2;overflow:hidden;-webkit-user-select:none;user-select:none;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}.loader,.error{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fffc;font-size:16px}.image-container{position:absolute;inset:0;width:100%;height:100%;margin:0;padding:0;box-sizing:border-box;display:flex;align-items:center;justify-content:center;touch-action:none}.preview-image{width:auto;height:auto;max-width:100%;max-height:100%;margin:auto;object-fit:contain;pointer-events:auto;will-change:transform;transform-origin:center center;transition:transform .1s ease-out;touch-action:none}.preview-image.dragging{cursor:move;transition:none}.preview-image.zoom-in{cursor:zoom-in}.preview-image.zoom-out{cursor:grab}.toolbar{position:absolute;bottom:30px;left:50%;transform:translate(-50%);display:flex;gap:16px;background-color:#ffffff1a;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);padding:10px 20px;border-radius:24px;pointer-events:auto;z-index:60}.toolbar-btn{background:none;border:none;color:#fff;cursor:pointer;padding:8px;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:background-color .2s}.toolbar-btn:hover{background-color:#fff3}.toolbar-btn:focus-visible{outline:2px solid white;outline-offset:2px}.toolbar-btn svg{width:24px;height:24px;fill:currentColor}.close-btn{position:absolute;top:20px;right:20px;z-index:60;background:none;border:none;color:#fff;cursor:pointer;padding:8px;border-radius:50%;background-color:#00000080;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.close-btn svg{width:24px;height:24px;fill:currentColor}.close-btn:hover{background:#fff3;transform:rotate(90deg)}.nav-btn{position:absolute;top:50%;transform:translateY(-50%);width:48px;height:48px;border-radius:50%;background:#ffffff1a;border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;z-index:10}.nav-btn:hover{background:#fff3}.nav-btn.prev{left:20px}.nav-btn.next{right:20px}.nav-btn svg{width:32px;height:32px;fill:currentColor;filter:drop-shadow(0 2px 4px rgba(0,0,0,.5))}.counter{position:absolute;top:20px;left:50%;transform:translate(-50%);color:#fffc;font-size:14px;background:#0000004d;padding:4px 12px;border-radius:12px;pointer-events:none;z-index:10}.hidden{display:none}\n"] }]
847
- }], ctorParameters: () => [], propDecorators: { src: [{ type: i0.Input, args: [{ isSignal: true, alias: "src", required: true }] }], images: [{ type: i0.Input, args: [{ isSignal: true, alias: "images", required: false }] }], initialIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "initialIndex", required: false }] }], customTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "customTemplate", required: false }] }], imgRef: [{ type: i0.ViewChild, args: ['imgRef', { isSignal: true }] }], onEscape: [{
1250
+ }, styles: [":host{display:block;--ng-img-background: rgba(0, 0, 0, .95);--ng-img-text-color: rgba(255, 255, 255, .8);--ng-img-z-index: 50;--ng-img-toolbar-bg: rgba(255, 255, 255, .1);--ng-img-toolbar-hover: rgba(255, 255, 255, .2);--ng-img-gap: 16px;--ng-img-item-bg: rgba(0, 0, 0, .3);--ng-img-thumb-strip-bg: rgba(0, 0, 0, .4);--ng-img-thumb-width: 60px;--ng-img-thumb-height: 40px;--ng-img-thumb-gap: 8px;--ng-img-thumb-border-radius: 6px;--ng-img-thumb-active-border: white}.overlay{position:fixed;inset:0;z-index:var(--ng-img-z-index);display:flex;align-items:center;justify-content:center;background-color:var(--ng-img-background);overflow:hidden;-webkit-user-select:none;user-select:none;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;touch-action:none}.loader,.error{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--ng-img-text-color);font-size:16px}.image-container{position:absolute;inset:0;width:100%;height:100%;margin:0;padding:0;box-sizing:border-box;display:flex;align-items:center;justify-content:center;touch-action:none}.preview-image{position:absolute;top:0;left:0;width:100%;height:100%;margin:auto;object-fit:contain;pointer-events:auto;will-change:transform;transform-origin:center center;transition:transform .3s cubic-bezier(.2,0,.2,1);touch-action:none}.preview-image.dragging,.preview-image.inertia,.preview-image.pinching{cursor:move;transition:none}.preview-image.zoom-in{cursor:zoom-in}.preview-image.zoom-out{cursor:grab}.toolbar{position:absolute;bottom:30px;left:50%;transform:translate(-50%);display:flex;gap:var(--ng-img-gap);background-color:var(--ng-img-toolbar-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);padding:10px 20px;border-radius:24px;pointer-events:auto;z-index:calc(var(--ng-img-z-index) + 10)}.toolbar-btn{background:none;border:none;color:#fff;cursor:pointer;padding:8px;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:background-color .2s}.toolbar-btn:hover{background-color:var(--ng-img-toolbar-hover)}.toolbar-btn:focus-visible{outline:2px solid white;outline-offset:2px}.toolbar-btn ::ng-deep svg{width:24px;height:24px;fill:currentColor}.close-btn{position:absolute;top:20px;right:20px;z-index:calc(var(--ng-img-z-index) + 10);background:none;border:none;color:#fff;cursor:pointer;padding:8px;border-radius:50%;background-color:#00000080;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.close-btn ::ng-deep svg{width:24px;height:24px;fill:currentColor}.close-btn:hover{background:var(--ng-img-toolbar-hover);transform:rotate(90deg)}.nav-btn{position:absolute;top:50%;transform:translateY(-50%);width:48px;height:48px;border-radius:50%;background:var(--ng-img-toolbar-bg);border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;z-index:10}.nav-btn:hover{background:var(--ng-img-toolbar-hover)}.nav-btn.prev{left:20px}.nav-btn.next{right:20px}.nav-btn ::ng-deep svg{width:32px;height:32px;fill:currentColor;filter:drop-shadow(0 2px 4px rgba(0,0,0,.5))}.counter{position:absolute;top:20px;left:50%;transform:translate(-50%);color:var(--ng-img-text-color);font-size:14px;background:var(--ng-img-item-bg);padding:4px 12px;border-radius:12px;pointer-events:none;z-index:10}.hidden{display:none}.thumbnail-strip{position:absolute;bottom:100px;left:50%;transform:translate(-50%);display:inline-block;white-space:nowrap;max-width:90%;width:auto;overflow-x:auto;overflow-y:hidden;padding:8px;background:var(--ng-img-thumb-strip-bg);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border-radius:12px;scroll-behavior:smooth;scrollbar-width:none;z-index:55;font-size:0}.thumbnail-strip::-webkit-scrollbar{display:none}.thumbnail-item{display:inline-block;vertical-align:middle;width:var(--ng-img-thumb-width);height:var(--ng-img-thumb-height);margin-right:var(--ng-img-thumb-gap);border-radius:var(--ng-img-thumb-border-radius);overflow:hidden;cursor:pointer;opacity:.6;transition:all .2s;border:2px solid transparent;box-sizing:border-box}.thumbnail-item:last-child{margin-right:0}.thumbnail-item:hover{opacity:.8}.thumbnail-item.active{opacity:1;border-color:var(--ng-img-thumb-active-border);transform:scale(1.1)}.thumbnail-item img{width:100%;height:100%;object-fit:cover}\n"] }]
1251
+ }], ctorParameters: () => [], propDecorators: { src: [{ type: i0.Input, args: [{ isSignal: true, alias: "src", required: true }] }], images: [{ type: i0.Input, args: [{ isSignal: true, alias: "images", required: false }] }], srcset: [{ type: i0.Input, args: [{ isSignal: true, alias: "srcset", required: false }] }], srcsets: [{ type: i0.Input, args: [{ isSignal: true, alias: "srcsets", required: false }] }], initialIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "initialIndex", required: false }] }], openerRect: [{ type: i0.Input, args: [{ isSignal: true, alias: "openerRect", required: false }] }], customTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "customTemplate", required: false }] }], toolbarConfig: [{ type: i0.Input, args: [{ isSignal: true, alias: "toolbarConfig", required: false }] }], imgRefs: [{ type: i0.ViewChildren, args: ['imgRef', { isSignal: true }] }], thumbRefs: [{ type: i0.ViewChildren, args: ['thumbRef', { isSignal: true }] }], showThumbnails: [{ type: i0.Input, args: [{ isSignal: true, alias: "showThumbnails", required: false }] }], showToolbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "showToolbar", required: false }] }], toolbarExtensions: [{ type: i0.Input, args: [{ isSignal: true, alias: "toolbarExtensions", required: false }] }], onEscape: [{
848
1252
  type: HostListener,
849
1253
  args: ['document:keydown.escape']
850
1254
  }], onMouseMove: [{
@@ -852,10 +1256,62 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
852
1256
  args: ['document:mousemove', ['$event']]
853
1257
  }] } });
854
1258
 
1259
+ /**
1260
+ * Directive to open the image preview.
1261
+ * Can be used on any element, but auto-detects `src` on `img` or nested `img`.
1262
+ */
855
1263
  class ImagesPreviewDirective {
1264
+ /**
1265
+ * High resolution image source to display in preview.
1266
+ * If empty, tries to read `src` from host element or first child `img`.
1267
+ */
856
1268
  highResSrc = '';
1269
+ /**
1270
+ * List of images for gallery navigation.
1271
+ * If provided, enables previous/next navigation buttons.
1272
+ */
857
1273
  previewImages = [];
1274
+ /**
1275
+ * Custom template to use for the preview.
1276
+ * If provided, overrides the default viewer UI.
1277
+ */
858
1278
  previewTemplate;
1279
+ /**
1280
+ * Configuration for the toolbar buttons.
1281
+ */
1282
+ toolbarConfig;
1283
+ /**
1284
+ * Optional srcset for the single image.
1285
+ */
1286
+ srcset;
1287
+ /**
1288
+ * List of srcsets corresponding to the `previewImages` array.
1289
+ */
1290
+ /**
1291
+ * List of srcsets corresponding to the `previewImages` array.
1292
+ */
1293
+ srcsets;
1294
+ /**
1295
+ * Whether to show the thumbnail strip.
1296
+ * @default true
1297
+ */
1298
+ showThumbnails = true;
1299
+ /**
1300
+ * Whether to show the toolbar.
1301
+ * @default true
1302
+ */
1303
+ showToolbar = true;
1304
+ /**
1305
+ * Custom template to render in the toolbar (e.g. for download buttons).
1306
+ */
1307
+ toolbarExtensions = null;
1308
+ /**
1309
+ * Type guard helper for strict template type checking.
1310
+ * Allows Angular Language Service to infer types in `previewTemplate`.
1311
+ */
1312
+ static ngTemplateContextGuard(directive, context) {
1313
+ return true;
1314
+ }
859
1315
  componentRef = null;
860
1316
  appRef = inject(ApplicationRef);
861
1317
  injector = inject(EnvironmentInjector);
@@ -878,11 +1334,12 @@ class ImagesPreviewDirective {
878
1334
  }
879
1335
  src = src || '';
880
1336
  if (src) {
881
- this.openPreview(src);
1337
+ const rect = hostEl.getBoundingClientRect();
1338
+ this.openPreview(src, rect);
882
1339
  }
883
1340
  }
884
1341
  cursor = 'pointer';
885
- openPreview(src) {
1342
+ openPreview(src, rect) {
886
1343
  if (!isPlatformBrowser(this.platformId))
887
1344
  return;
888
1345
  // Create Component
@@ -891,6 +1348,7 @@ class ImagesPreviewDirective {
891
1348
  });
892
1349
  // Set Inputs
893
1350
  this.componentRef.setInput('src', src);
1351
+ this.componentRef.setInput('openerRect', rect);
894
1352
  if (this.previewImages.length > 0) {
895
1353
  this.componentRef.setInput('images', this.previewImages);
896
1354
  const index = this.previewImages.indexOf(src);
@@ -899,6 +1357,18 @@ class ImagesPreviewDirective {
899
1357
  if (this.previewTemplate) {
900
1358
  this.componentRef.setInput('customTemplate', this.previewTemplate);
901
1359
  }
1360
+ if (this.toolbarConfig) {
1361
+ this.componentRef.setInput('toolbarConfig', this.toolbarConfig);
1362
+ }
1363
+ if (this.srcset) {
1364
+ this.componentRef.setInput('srcset', this.srcset);
1365
+ }
1366
+ if (this.srcsets) {
1367
+ this.componentRef.setInput('srcsets', this.srcsets);
1368
+ }
1369
+ this.componentRef.setInput('showThumbnails', this.showThumbnails);
1370
+ this.componentRef.setInput('showToolbar', this.showToolbar);
1371
+ this.componentRef.setInput('toolbarExtensions', this.toolbarExtensions);
902
1372
  // Set Callbacks
903
1373
  this.componentRef.instance.closeCallback = () => this.destroyPreview();
904
1374
  // Attach to App
@@ -918,7 +1388,7 @@ class ImagesPreviewDirective {
918
1388
  this.destroyPreview();
919
1389
  }
920
1390
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.7", ngImport: i0, type: ImagesPreviewDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
921
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.7", type: ImagesPreviewDirective, isStandalone: true, selector: "[ngImagesPreview]", inputs: { highResSrc: ["ngImagesPreview", "highResSrc"], previewImages: "previewImages", previewTemplate: "previewTemplate" }, host: { listeners: { "click": "onClick($event)", "style.cursor": "cursor()" } }, ngImport: i0 });
1391
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.7", type: ImagesPreviewDirective, isStandalone: true, selector: "[ngImagesPreview]", inputs: { highResSrc: ["ngImagesPreview", "highResSrc"], previewImages: "previewImages", previewTemplate: "previewTemplate", toolbarConfig: "toolbarConfig", srcset: "srcset", srcsets: "srcsets", showThumbnails: "showThumbnails", showToolbar: "showToolbar", toolbarExtensions: "toolbarExtensions" }, host: { listeners: { "click": "onClick($event)", "style.cursor": "cursor()" } }, ngImport: i0 });
922
1392
  }
923
1393
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImport: i0, type: ImagesPreviewDirective, decorators: [{
924
1394
  type: Directive,
@@ -933,6 +1403,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
933
1403
  type: Input
934
1404
  }], previewTemplate: [{
935
1405
  type: Input
1406
+ }], toolbarConfig: [{
1407
+ type: Input
1408
+ }], srcset: [{
1409
+ type: Input
1410
+ }], srcsets: [{
1411
+ type: Input
1412
+ }], showThumbnails: [{
1413
+ type: Input
1414
+ }], showToolbar: [{
1415
+ type: Input
1416
+ }], toolbarExtensions: [{
1417
+ type: Input
936
1418
  }], onClick: [{
937
1419
  type: HostListener,
938
1420
  args: ['click', ['$event']]
@@ -941,6 +1423,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
941
1423
  args: ['style.cursor']
942
1424
  }] } });
943
1425
 
1426
+ class NgImagesPreviewModule {
1427
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.7", ngImport: i0, type: NgImagesPreviewModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
1428
+ static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.0.7", ngImport: i0, type: NgImagesPreviewModule, imports: [ImagesPreviewComponent, ImagesPreviewDirective], exports: [ImagesPreviewComponent, ImagesPreviewDirective] });
1429
+ static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.0.7", ngImport: i0, type: NgImagesPreviewModule, imports: [ImagesPreviewComponent] });
1430
+ }
1431
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImport: i0, type: NgImagesPreviewModule, decorators: [{
1432
+ type: NgModule,
1433
+ args: [{
1434
+ imports: [ImagesPreviewComponent, ImagesPreviewDirective],
1435
+ exports: [ImagesPreviewComponent, ImagesPreviewDirective]
1436
+ }]
1437
+ }] });
1438
+
944
1439
  /*
945
1440
  * Public API Surface of ng-images-preview
946
1441
  */
@@ -949,5 +1444,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
949
1444
  * Generated bundle index. Do not edit.
950
1445
  */
951
1446
 
952
- export { ImagesPreviewComponent, ImagesPreviewDirective };
1447
+ export { ImagesPreviewComponent, ImagesPreviewDirective, NgImagesPreviewModule };
953
1448
  //# sourceMappingURL=ng-images-preview.mjs.map