ng-images-preview 1.0.2 → 2.0.0

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,7 +214,7 @@ 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(),
79
219
  }), ...(ngDevMode ? [{ debugName: "state" }] : []));
80
220
  // Actions object for template
@@ -102,18 +242,92 @@ class ImagesPreviewComponent {
102
242
  cachedConstraints = null;
103
243
  lastTimestamp = 0;
104
244
  rafId = null;
105
- FRICTION = 0.92; // Heavier feel
245
+ FRICTION = 0.95; // Super smooth glide
106
246
  VELOCITY_THRESHOLD = 0.01;
107
247
  MAX_VELOCITY = 3; // Cap speed to prevent teleporting
108
- transformStyle = computed(() => {
248
+ // Updated transform logic for slide buffer
249
+ getTransform(offset) {
250
+ const viewportWidth = window.innerWidth;
251
+ const spacing = 16; // Gap between images
252
+ const baseOffset = offset * (viewportWidth + spacing);
253
+ // Dynamic movement from touch/inertia
254
+ // Global translateX applies to the whole "track"
255
+ const x = this.translateX() + baseOffset;
256
+ // Y-axis drag (Pull-to-Close) usually only affects the active image visually,
257
+ // but moving the whole track is acceptable and simpler.
258
+ // Ideally, neighbors stay at Y=0.
259
+ const effectiveY = offset === 0 ? this.translateY() : 0;
260
+ const effectiveScale = offset === 0 ? this.scale() : 1;
261
+ const effectiveRotate = offset === 0 ? this.rotate() : 0;
262
+ const flipH = offset === 0 ? this.flipH() : false;
263
+ const flipV = offset === 0 ? this.flipV() : false;
264
+ const scaleX = flipH ? -1 : 1;
265
+ const scaleY = flipV ? -1 : 1;
266
+ // FLIP override
267
+ if (offset === 0) {
268
+ const flip = this.flipTransform();
269
+ if (flip)
270
+ return flip;
271
+ }
272
+ return `translate3d(${x}px, ${effectiveY}px, 0) scale(${effectiveScale}) rotate(${effectiveRotate}deg) scaleX(${scaleX}) scaleY(${scaleY})`;
273
+ }
274
+ // FLIP State
275
+ flipTransform = signal('', ...(ngDevMode ? [{ debugName: "flipTransform" }] : []));
276
+ // Helper to get current active image element
277
+ getCurrentImageElement() {
278
+ const buffer = this.activeBuffer();
279
+ const index = buffer.findIndex((item) => item.offset === 0);
280
+ if (index === -1)
281
+ return undefined;
282
+ return this.imgRefs()[index]?.nativeElement;
283
+ }
284
+ runFlipAnimation(opener) {
285
+ if (!isPlatformBrowser(this.platformId))
286
+ return;
287
+ const imgEl = this.getCurrentImageElement();
288
+ if (!imgEl)
289
+ return;
290
+ // 1. First: Final state is already rendered (centered, max fit)
291
+ const finalRect = imgEl.getBoundingClientRect();
292
+ // 2. Invert: Calculate scale and translate to match opener
293
+ const scaleX = opener.width / finalRect.width;
294
+ const scaleY = opener.height / finalRect.height;
295
+ // Note: we use the larger scale to crop?
296
+ // Or fit?
297
+ // Usually we want to fit.
298
+ // Let's matching fitting.
299
+ // Actually, most simple FLIP matches dimensions exactly.
300
+ // But aspect ratios might differ.
301
+ // Let's just match the rect exactly.
302
+ const deltaX = opener.left - finalRect.left + (opener.width - finalRect.width) / 2;
303
+ const deltaY = opener.top - finalRect.top + (opener.height - finalRect.height) / 2;
304
+ // Apply Invert Transform immediately (no transition)
305
+ imgEl.style.transition = 'none';
306
+ this.flipTransform.set(`translate3d(${deltaX}px, ${deltaY}px, 0) scale(${scaleX}, ${scaleY})`);
307
+ // Force Reflow
308
+ imgEl.offsetHeight;
309
+ // 3. Play
310
+ requestAnimationFrame(() => {
311
+ // Enable transition
312
+ imgEl.style.transition = 'transform 300ms cubic-bezier(0.2, 0, 0.2, 1)';
313
+ // Remove override (animates to Final)
314
+ this.flipTransform.set('');
315
+ this.flipAnimDone = true;
316
+ // CLEANUP: Remove transition after animation wraps up so dragging/panning is instant
317
+ setTimeout(() => {
318
+ imgEl.style.transition = '';
319
+ }, 300);
320
+ });
321
+ }
322
+ overlayBackground = computed(() => {
323
+ const y = Math.abs(this.translateY());
109
324
  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" }] : []));
325
+ if (scale === 1 && y > 0) {
326
+ const opacity = Math.max(0, 0.95 - y / 400); // Fade out as we drag down
327
+ return `rgba(0, 0, 0, ${opacity})`;
328
+ }
329
+ return 'var(--ng-img-background)';
330
+ }, ...(ngDevMode ? [{ debugName: "overlayBackground" }] : []));
117
331
  onEscape() {
118
332
  this.close();
119
333
  }
@@ -134,13 +348,19 @@ class ImagesPreviewComponent {
134
348
  this.translateY.set(nextY);
135
349
  return;
136
350
  }
137
- const img = this.imgRef()?.nativeElement;
351
+ const img = this.getCurrentImageElement();
138
352
  if (!img) {
139
353
  this.translateX.set(nextX);
140
354
  this.translateY.set(nextY);
141
355
  return;
142
356
  }
143
357
  const scale = this.scale();
358
+ // If scale is 1, we allow free movement for Pull-to-Close and Swipe Nav
359
+ if (scale === 1) {
360
+ this.translateX.set(nextX);
361
+ this.translateY.set(nextY);
362
+ return;
363
+ }
144
364
  const rotate = Math.abs(this.rotate() % 180);
145
365
  const isRotated = rotate === 90;
146
366
  const baseWidth = img.offsetWidth;
@@ -158,7 +378,7 @@ class ImagesPreviewComponent {
158
378
  this.translateY.set(clampedY);
159
379
  }
160
380
  onTouchMove(event) {
161
- if (!this.isDragging() && !this.isPinching)
381
+ if (!this.isDragging() && !this.isPinching())
162
382
  return;
163
383
  // Prevent default to stop page scrolling/zooming
164
384
  if (event.cancelable) {
@@ -166,7 +386,7 @@ class ImagesPreviewComponent {
166
386
  }
167
387
  const touches = event.touches;
168
388
  // One finger: Pan
169
- if (touches.length === 1 && this.isDragging() && !this.isPinching) {
389
+ if (touches.length === 1 && this.isDragging() && !this.isPinching()) {
170
390
  const now = Date.now();
171
391
  // Add point to history
172
392
  this.touchHistory.push({
@@ -190,11 +410,30 @@ class ImagesPreviewComponent {
190
410
  // Apply rubber banding if out of bounds
191
411
  // Use cached constraints to avoid reflows
192
412
  const constraints = this.cachedConstraints || this.getConstraints();
193
- if (nextX > constraints.maxX) {
194
- nextX = constraints.maxX + (nextX - constraints.maxX) * 0.5;
413
+ // At scale 1, we want linear drag for pull-to-close (Y) and swipe (X)
414
+ // But we can keep rubber banding if it feels good.
415
+ // For Pull-to-Close, standard is 1:1 or slightly resisted.
416
+ // Let's stick to the current logic which applies resistance if "out of bounds".
417
+ // Since at scale 1 bounds are 0, it will apply resistance immediately.
418
+ // We want easier pull.
419
+ if (this.scale() === 1) {
420
+ // Reduce resistance
421
+ nextY = currentY + deltaY;
422
+ nextX = currentX + deltaX;
195
423
  }
196
- else if (nextX < -constraints.maxX) {
197
- nextX = -constraints.maxX + (nextX + constraints.maxX) * 0.5;
424
+ else {
425
+ if (nextX > constraints.maxX) {
426
+ nextX = constraints.maxX + (nextX - constraints.maxX) * 0.5;
427
+ }
428
+ else if (nextX < -constraints.maxX) {
429
+ nextX = -constraints.maxX + (nextX + constraints.maxX) * 0.5;
430
+ }
431
+ if (nextY > constraints.maxY) {
432
+ nextY = constraints.maxY + (nextY - constraints.maxY) * 0.5;
433
+ }
434
+ else if (nextY < -constraints.maxY) {
435
+ nextY = -constraints.maxY + (nextY + constraints.maxY) * 0.5;
436
+ }
198
437
  }
199
438
  if (nextY > constraints.maxY) {
200
439
  nextY = constraints.maxY + (nextY - constraints.maxY) * 0.5;
@@ -208,15 +447,44 @@ class ImagesPreviewComponent {
208
447
  this.lastTouchY = touches[0].clientY;
209
448
  }
210
449
  // Two fingers: Pinch Zoom
211
- if (touches.length === 2) {
450
+ if (touches.length === 2 && this.initialPinchDistance > 0) {
212
451
  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();
452
+ const scaleFactor = distance / this.initialPinchDistance;
453
+ // Calculate new scale with limits
454
+ const rawNewScale = this.initialScale * scaleFactor;
455
+ const newScale = Math.min(Math.max(rawNewScale, this.MIN_SCALE), this.MAX_SCALE);
456
+ // Calculate new translation to keep focal point fixed
457
+ const effectiveRatio = newScale / this.initialScale;
458
+ const currentCenter = this.getCenter(touches);
459
+ const cx = currentCenter.x - window.innerWidth / 2;
460
+ const cy = currentCenter.y - window.innerHeight / 2;
461
+ const sx = this.initialPinchCenter.x - window.innerWidth / 2;
462
+ const sy = this.initialPinchCenter.y - window.innerHeight / 2;
463
+ let newTx = cx - (sx - this.initialTranslateX) * effectiveRatio;
464
+ let newTy = cy - (sy - this.initialTranslateY) * effectiveRatio;
465
+ // --- Clamp Logic (Inline for atomicity) ---
466
+ const img = this.getCurrentImageElement();
467
+ if (img) {
468
+ const rotate = Math.abs(this.rotate() % 180);
469
+ const isRotated = rotate === 90;
470
+ const baseWidth = img.offsetWidth;
471
+ const baseHeight = img.offsetHeight;
472
+ const currentWidth = (isRotated ? baseHeight : baseWidth) * newScale;
473
+ const currentHeight = (isRotated ? baseWidth : baseHeight) * newScale;
474
+ const maxTx = Math.max(0, (currentWidth - window.innerWidth) / 2);
475
+ const maxTy = Math.max(0, (currentHeight - window.innerHeight) / 2);
476
+ if (currentWidth <= window.innerWidth)
477
+ newTx = 0;
478
+ else if (Math.abs(newTx) > maxTx)
479
+ newTx = Math.sign(newTx) * maxTx;
480
+ if (currentHeight <= window.innerHeight)
481
+ newTy = 0;
482
+ else if (Math.abs(newTy) > maxTy)
483
+ newTy = Math.sign(newTy) * maxTy;
219
484
  }
485
+ this.scale.set(newScale);
486
+ this.translateX.set(newTx);
487
+ this.translateY.set(newTy);
220
488
  }
221
489
  }
222
490
  getConstraints() {
@@ -226,7 +494,7 @@ class ImagesPreviewComponent {
226
494
  // The `cachedConstraints` property is used *outside* this method.
227
495
  if (!isPlatformBrowser(this.platformId))
228
496
  return { maxX: 0, maxY: 0 };
229
- const img = this.imgRef()?.nativeElement;
497
+ const img = this.getCurrentImageElement();
230
498
  if (!img)
231
499
  return { maxX: 0, maxY: 0 };
232
500
  const scale = this.scale();
@@ -246,7 +514,7 @@ class ImagesPreviewComponent {
246
514
  clampPosition() {
247
515
  if (!isPlatformBrowser(this.platformId))
248
516
  return;
249
- const img = this.imgRef()?.nativeElement;
517
+ const img = this.getCurrentImageElement();
250
518
  if (!img)
251
519
  return;
252
520
  const scale = this.scale();
@@ -283,6 +551,7 @@ class ImagesPreviewComponent {
283
551
  this.translateY.set(newY);
284
552
  }
285
553
  startInertia() {
554
+ this.isInertia.set(true); // Disable CSS transition
286
555
  let lastTime = Date.now();
287
556
  const step = () => {
288
557
  if (!this.isDragging() &&
@@ -292,7 +561,6 @@ class ImagesPreviewComponent {
292
561
  const dt = Math.min(now - lastTime, 64); // Cap dt to avoid huge jumps if lag
293
562
  lastTime = now;
294
563
  if (dt === 0) {
295
- // Skip if 0ms passed (can happen on fast screens)
296
564
  this.rafId = requestAnimationFrame(step);
297
565
  return;
298
566
  }
@@ -300,14 +568,12 @@ class ImagesPreviewComponent {
300
568
  this.velocityX = Math.max(-this.MAX_VELOCITY, Math.min(this.MAX_VELOCITY, this.velocityX));
301
569
  this.velocityY = Math.max(-this.MAX_VELOCITY, Math.min(this.MAX_VELOCITY, this.velocityY));
302
570
  // Time-based friction
303
- // Standard friction is 0.95 per 16ms frame
304
571
  const frictionFactor = Math.pow(this.FRICTION, dt / 16);
305
572
  this.velocityX *= frictionFactor;
306
573
  this.velocityY *= frictionFactor;
307
574
  let nextX = this.translateX() + this.velocityX * dt;
308
575
  let nextY = this.translateY() + this.velocityY * dt;
309
576
  // Check bounds during inertia
310
- // Hard stop/bounce logic can be improved here if needed
311
577
  const constraints = this.cachedConstraints || this.getConstraints();
312
578
  if (nextX > constraints.maxX) {
313
579
  nextX = constraints.maxX;
@@ -331,14 +597,12 @@ class ImagesPreviewComponent {
331
597
  }
332
598
  else {
333
599
  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
600
  }
338
601
  };
339
602
  this.rafId = requestAnimationFrame(step);
340
603
  }
341
604
  stopInertia() {
605
+ this.isInertia.set(false); // Re-enable CSS transition
342
606
  if (this.rafId) {
343
607
  cancelAnimationFrame(this.rafId);
344
608
  this.rafId = null;
@@ -380,6 +644,27 @@ class ImagesPreviewComponent {
380
644
  event.stopPropagation();
381
645
  }
382
646
  onMouseUp() {
647
+ if (this.isDragging()) {
648
+ // Check Pull to Close for Mouse
649
+ if (this.scale() === 1) {
650
+ const y = this.translateY();
651
+ if (Math.abs(y) > 100) {
652
+ this.close();
653
+ return;
654
+ }
655
+ // Reset position if not closed
656
+ if (y !== 0) {
657
+ this.translateY.set(0);
658
+ }
659
+ const x = this.translateX();
660
+ if (x !== 0) {
661
+ this.translateX.set(0);
662
+ }
663
+ }
664
+ else {
665
+ this.snapBack();
666
+ }
667
+ }
383
668
  this.isDragging.set(false);
384
669
  }
385
670
  onTouchEnd(event) {
@@ -405,42 +690,55 @@ class ImagesPreviewComponent {
405
690
  this.velocityY = 0;
406
691
  }
407
692
  this.isDragging.set(false);
408
- this.isPinching = false;
693
+ this.isPinching.set(false);
409
694
  this.initialPinchDistance = 0;
410
695
  // Swipe Navigation (at 1x scale)
411
696
  if (this.scale() === 1) {
412
697
  const x = this.translateX();
413
- const threshold = 50; // px
698
+ const y = this.translateY();
699
+ // Pull to Close
700
+ if (Math.abs(y) > 100) {
701
+ this.close();
702
+ return;
703
+ }
704
+ const threshold = window.innerWidth * 0.25;
705
+ const images = this.images();
706
+ const total = images ? images.length : 0;
707
+ const index = this.currentIndex();
414
708
  if (x < -threshold) {
415
- this.next();
709
+ // NEXT (Slide Left)
710
+ if (index < total - 1) {
711
+ this.animateSlide(-1);
712
+ }
713
+ else {
714
+ this.translateX.set(0);
715
+ this.translateY.set(0);
716
+ }
416
717
  return;
417
718
  }
418
719
  else if (x > threshold) {
419
- this.prev();
720
+ // PREV (Slide Right)
721
+ if (index > 0) {
722
+ this.animateSlide(1);
723
+ }
724
+ else {
725
+ this.translateX.set(0);
726
+ this.translateY.set(0);
727
+ }
420
728
  return;
421
729
  }
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;
730
+ // Snap back
731
+ this.translateX.set(0);
732
+ this.translateY.set(0);
436
733
  }
437
734
  else {
438
735
  this.startInertia();
439
736
  }
737
+ return;
440
738
  }
441
- else if (touches.length === 1 && this.isPinching) {
739
+ else if (touches.length === 1 && this.isPinching()) {
442
740
  // Transition from pinch to pan
443
- this.isPinching = false;
741
+ this.isPinching.set(false);
444
742
  this.isDragging.set(true);
445
743
  this.lastTouchX = touches[0].clientX;
446
744
  this.lastTouchY = touches[0].clientY;
@@ -454,18 +752,32 @@ class ImagesPreviewComponent {
454
752
  }
455
753
  lastTouchX = 0;
456
754
  lastTouchY = 0;
755
+ initialPinchCenter = { x: 0, y: 0 };
756
+ initialTranslateX = 0;
757
+ initialTranslateY = 0;
457
758
  getDistance(touches) {
458
759
  return Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY);
459
760
  }
761
+ getCenter(touches) {
762
+ return {
763
+ x: (touches[0].clientX + touches[1].clientX) / 2,
764
+ y: (touches[0].clientY + touches[1].clientY) / 2,
765
+ };
766
+ }
460
767
  close() {
461
768
  this.closeCallback();
462
769
  }
463
- onImageLoad() {
464
- this.isLoading.set(false);
770
+ onImageLoad(src) {
771
+ this.loadedImages.update(set => {
772
+ const newSet = new Set(set);
773
+ newSet.add(src);
774
+ return newSet;
775
+ });
465
776
  this.hasError.set(false);
466
777
  }
467
- onImageError() {
468
- this.isLoading.set(false);
778
+ onImageError(src) {
779
+ // this.loadedImages.update... (maybe not mark as loaded? or separate error tracking)
780
+ // For now, keep error simple
469
781
  this.hasError.set(true);
470
782
  }
471
783
  // Zoom
@@ -521,14 +833,45 @@ class ImagesPreviewComponent {
521
833
  this.currentIndex.update((i) => i - 1);
522
834
  }
523
835
  }
836
+ jumpTo(index) {
837
+ this.currentIndex.set(index);
838
+ this.reset();
839
+ }
840
+ // Slide Animation
841
+ animateSlide(direction) {
842
+ const spacing = 16;
843
+ const width = window.innerWidth + spacing;
844
+ // direction -1 (Next) -> move to -width
845
+ // direction 1 (Prev) -> move to width
846
+ // Wait. If I swipe Left (Next), x is negative. Target should be -width.
847
+ // If I swipe Right (Prev), x is positive. Target should be width.
848
+ // So target = direction * width? No.
849
+ // If direction is "Next" (index + 1), I want to slide to the Left (-X).
850
+ // Let's pass the sign of movement explicitly.
851
+ const target = direction === -1 ? -width : width;
852
+ this.translateX.set(target);
853
+ // Wait for CSS transition (0.3s)
854
+ setTimeout(() => {
855
+ // Update Index (silent swap)
856
+ this.isInertia.set(true); // Disable transition
857
+ if (direction === -1)
858
+ this.next();
859
+ else
860
+ this.prev();
861
+ this.translateX.set(0);
862
+ // Re-enable transition
863
+ setTimeout(() => {
864
+ this.isInertia.set(false);
865
+ }, 50);
866
+ }, 300);
867
+ }
524
868
  // Mouse Interaction
525
869
  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)
870
+ // Allow dragging even at scale 1 for Pull-to-Close and Swipe-like nav (if intended)
871
+ if (this.isDragging())
872
+ return;
873
+ // Only trigger on Left Click
874
+ if (event.button !== 0)
532
875
  return;
533
876
  this.isDragging.set(true);
534
877
  this.startX = event.clientX;
@@ -540,36 +883,38 @@ class ImagesPreviewComponent {
540
883
  // Touch Interaction (bound in template)
541
884
  onTouchStart(event) {
542
885
  this.stopInertia(); // Cancel any ongoing movement
886
+ // if (event.cancelable) event.preventDefault(); // Removed to restore Tap-to-Close (click events)
887
+ // touch-action: none in CSS handles scroll prevention.
543
888
  const touches = event.touches;
544
889
  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
- }
890
+ // Single touch: Pan.
891
+ // Allow dragging from anywhere in the container (better UX for Pull-to-Close)
892
+ this.isDragging.set(true);
893
+ this.lastTouchX = touches[0].clientX;
894
+ this.lastTouchY = touches[0].clientY;
895
+ this.lastTimestamp = Date.now();
896
+ // Initialize physics state
897
+ this.velocityX = 0;
898
+ this.velocityY = 0;
899
+ this.touchHistory = [
900
+ {
901
+ x: touches[0].clientX,
902
+ y: touches[0].clientY,
903
+ time: Date.now(),
904
+ },
905
+ ];
906
+ // Cache layout to prevent thrashing
907
+ this.cachedConstraints = this.getConstraints();
566
908
  }
567
909
  else if (touches.length === 2) {
568
910
  // Two fingers: Pinch
569
- this.isPinching = true;
911
+ this.isPinching.set(true);
570
912
  this.isDragging.set(false); // Stop panning
571
913
  this.initialPinchDistance = this.getDistance(touches);
572
914
  this.initialScale = this.scale();
915
+ this.initialPinchCenter = this.getCenter(touches);
916
+ this.initialTranslateX = this.translateX();
917
+ this.initialTranslateY = this.translateY();
573
918
  // Clear caching on pinch (scale changes will invalidate limits)
574
919
  this.cachedConstraints = null;
575
920
  // Prevent default to avoid browser zoom
@@ -578,7 +923,7 @@ class ImagesPreviewComponent {
578
923
  }
579
924
  }
580
925
  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: `
926
+ 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
927
  <div
583
928
  class="overlay"
584
929
  [@fadeInOut]
@@ -587,6 +932,7 @@ class ImagesPreviewComponent {
587
932
  tabindex="0"
588
933
  role="button"
589
934
  aria-label="Close preview overlay"
935
+ [style.background-color]="overlayBackground()"
590
936
  >
591
937
  <!-- Custom Template Support -->
592
938
  @if (customTemplate(); as template) {
@@ -595,7 +941,7 @@ class ImagesPreviewComponent {
595
941
  ></ng-container>
596
942
  } @else {
597
943
  <!-- Loading -->
598
- @if (isLoading()) {
944
+ @if (!loadedImages().has(src()) && !hasError()) {
599
945
  <div class="loader">Loading...</div>
600
946
  }
601
947
 
@@ -611,27 +957,32 @@ class ImagesPreviewComponent {
611
957
  (touchstart)="onTouchStart($event)"
612
958
  tabindex="-1"
613
959
  >
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
- />
960
+ @for (item of activeBuffer(); track item.src) {
961
+ <img
962
+ #imgRef
963
+ [src]="item.src"
964
+ [srcset]="item.srcset || ''"
965
+ [class.opacity-0]="item.offset === 0 && (!loadedImages().has(item.src) || hasError())"
966
+ class="preview-image"
967
+ [class.dragging]="isDragging()"
968
+ [class.inertia]="isInertia()"
969
+ [class.pinching]="isPinching()"
970
+ [class.zoom-in]="scale() === 1"
971
+ [class.zoom-out]="scale() > 1"
972
+ [class.hidden]="item.offset !== 0 && scale() > 1"
973
+ [style.transform]="getTransform(item.offset)"
974
+ (load)="onImageLoad(item.src)"
975
+ (error)="onImageError(item.src)"
976
+ (mousedown)="onMouseDown($event)"
977
+ (click)="$event.stopPropagation()"
978
+ draggable="false"
979
+ alt="Preview"
980
+ />
981
+ }
630
982
  </div>
631
983
 
632
984
  <!-- 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>
985
+ <button class="close-btn" (click)="close()" aria-label="Close preview" [innerHTML]="icons.close">
635
986
  </button>
636
987
 
637
988
  <!-- Navigation -->
@@ -643,8 +994,8 @@ class ImagesPreviewComponent {
643
994
  (click)="prev(); $event.stopPropagation()"
644
995
  (mousedown)="$event.stopPropagation()"
645
996
  aria-label="Previous image"
997
+ [innerHTML]="icons.prev"
646
998
  >
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
999
  </button>
649
1000
  }
650
1001
  <!-- Check if not last -->
@@ -654,8 +1005,8 @@ class ImagesPreviewComponent {
654
1005
  (click)="next(); $event.stopPropagation()"
655
1006
  (mousedown)="$event.stopPropagation()"
656
1007
  aria-label="Next image"
1008
+ [innerHTML]="icons.next"
657
1009
  >
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
1010
  </button>
660
1011
  }
661
1012
 
@@ -664,40 +1015,61 @@ class ImagesPreviewComponent {
664
1015
  }
665
1016
 
666
1017
  <!-- Toolbar -->
1018
+ <!-- Toolbar -->
1019
+ @if (showToolbar()) {
667
1020
  <div
668
1021
  class="toolbar"
669
1022
  (click)="$event.stopPropagation()"
670
1023
  (keydown)="onToolbarKey($event)"
671
1024
  tabindex="0"
672
1025
  >
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>
1026
+ <!-- Flip -->
1027
+ @if (toolbarConfig().showFlip) {
1028
+ <button class="toolbar-btn" (click)="flipHorizontal()" aria-label="Flip Horizontal" [innerHTML]="icons.flipH">
1029
+ </button>
1030
+ <button class="toolbar-btn" (click)="flipVertical()" aria-label="Flip Vertical" [innerHTML]="icons.flipV">
1031
+ </button>
1032
+ }
1033
+
1034
+ <!-- Extensions (Custom Buttons) -->
1035
+ @if (toolbarExtensions()) {
1036
+ <ng-container *ngTemplateOutlet="toolbarExtensions(); context: { $implicit: images()![currentIndex()] }"></ng-container>
1037
+ }
1038
+ <!-- Rotate -->
1039
+ @if (toolbarConfig().showRotate) {
1040
+ <button class="toolbar-btn" (click)="rotateLeft()" aria-label="Rotate Left" [innerHTML]="icons.rotateLeft">
1041
+ </button>
1042
+ <button class="toolbar-btn" (click)="rotateRight()" aria-label="Rotate Right" [innerHTML]="icons.rotateRight">
1043
+ </button>
1044
+ }
1045
+ <!-- Zoom -->
1046
+ @if (toolbarConfig().showZoom) {
1047
+ <button class="toolbar-btn" (click)="zoomOut()" aria-label="Zoom Out" [innerHTML]="icons.zoomOut">
1048
+ </button>
1049
+ <button class="toolbar-btn" (click)="zoomIn()" aria-label="Zoom In" [innerHTML]="icons.zoomIn">
1050
+ </button>
1051
+ }
1052
+ </div>
1053
+ }
1054
+ }
1055
+
1056
+ <!-- Thumbnails -->
1057
+ @if (images() && images()!.length > 1 && showThumbnails()) {
1058
+ <div class="thumbnail-strip" (click)="$event.stopPropagation()">
1059
+ @for (img of images(); track img; let i = $index) {
1060
+ <div
1061
+ #thumbRef
1062
+ class="thumbnail-item"
1063
+ [class.active]="i === currentIndex()"
1064
+ (click)="jumpTo(i)"
1065
+ >
1066
+ <img [src]="img" loading="lazy" alt="Thumbnail">
1067
+ </div>
1068
+ }
697
1069
  </div>
698
1070
  }
699
1071
  </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: [
1072
+ `, 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
1073
  trigger('fadeInOut', [
702
1074
  transition(':enter', [
703
1075
  style({ opacity: 0 }),
@@ -718,6 +1090,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
718
1090
  tabindex="0"
719
1091
  role="button"
720
1092
  aria-label="Close preview overlay"
1093
+ [style.background-color]="overlayBackground()"
721
1094
  >
722
1095
  <!-- Custom Template Support -->
723
1096
  @if (customTemplate(); as template) {
@@ -726,7 +1099,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
726
1099
  ></ng-container>
727
1100
  } @else {
728
1101
  <!-- Loading -->
729
- @if (isLoading()) {
1102
+ @if (!loadedImages().has(src()) && !hasError()) {
730
1103
  <div class="loader">Loading...</div>
731
1104
  }
732
1105
 
@@ -742,27 +1115,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
742
1115
  (touchstart)="onTouchStart($event)"
743
1116
  tabindex="-1"
744
1117
  >
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
- />
1118
+ @for (item of activeBuffer(); track item.src) {
1119
+ <img
1120
+ #imgRef
1121
+ [src]="item.src"
1122
+ [srcset]="item.srcset || ''"
1123
+ [class.opacity-0]="item.offset === 0 && (!loadedImages().has(item.src) || hasError())"
1124
+ class="preview-image"
1125
+ [class.dragging]="isDragging()"
1126
+ [class.inertia]="isInertia()"
1127
+ [class.pinching]="isPinching()"
1128
+ [class.zoom-in]="scale() === 1"
1129
+ [class.zoom-out]="scale() > 1"
1130
+ [class.hidden]="item.offset !== 0 && scale() > 1"
1131
+ [style.transform]="getTransform(item.offset)"
1132
+ (load)="onImageLoad(item.src)"
1133
+ (error)="onImageError(item.src)"
1134
+ (mousedown)="onMouseDown($event)"
1135
+ (click)="$event.stopPropagation()"
1136
+ draggable="false"
1137
+ alt="Preview"
1138
+ />
1139
+ }
761
1140
  </div>
762
1141
 
763
1142
  <!-- 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>
1143
+ <button class="close-btn" (click)="close()" aria-label="Close preview" [innerHTML]="icons.close">
766
1144
  </button>
767
1145
 
768
1146
  <!-- Navigation -->
@@ -774,8 +1152,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
774
1152
  (click)="prev(); $event.stopPropagation()"
775
1153
  (mousedown)="$event.stopPropagation()"
776
1154
  aria-label="Previous image"
1155
+ [innerHTML]="icons.prev"
777
1156
  >
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
1157
  </button>
780
1158
  }
781
1159
  <!-- Check if not last -->
@@ -785,8 +1163,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
785
1163
  (click)="next(); $event.stopPropagation()"
786
1164
  (mousedown)="$event.stopPropagation()"
787
1165
  aria-label="Next image"
1166
+ [innerHTML]="icons.next"
788
1167
  >
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
1168
  </button>
791
1169
  }
792
1170
 
@@ -795,36 +1173,57 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
795
1173
  }
796
1174
 
797
1175
  <!-- Toolbar -->
1176
+ <!-- Toolbar -->
1177
+ @if (showToolbar()) {
798
1178
  <div
799
1179
  class="toolbar"
800
1180
  (click)="$event.stopPropagation()"
801
1181
  (keydown)="onToolbarKey($event)"
802
1182
  tabindex="0"
803
1183
  >
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>
1184
+ <!-- Flip -->
1185
+ @if (toolbarConfig().showFlip) {
1186
+ <button class="toolbar-btn" (click)="flipHorizontal()" aria-label="Flip Horizontal" [innerHTML]="icons.flipH">
1187
+ </button>
1188
+ <button class="toolbar-btn" (click)="flipVertical()" aria-label="Flip Vertical" [innerHTML]="icons.flipV">
1189
+ </button>
1190
+ }
1191
+
1192
+ <!-- Extensions (Custom Buttons) -->
1193
+ @if (toolbarExtensions()) {
1194
+ <ng-container *ngTemplateOutlet="toolbarExtensions(); context: { $implicit: images()![currentIndex()] }"></ng-container>
1195
+ }
1196
+ <!-- Rotate -->
1197
+ @if (toolbarConfig().showRotate) {
1198
+ <button class="toolbar-btn" (click)="rotateLeft()" aria-label="Rotate Left" [innerHTML]="icons.rotateLeft">
1199
+ </button>
1200
+ <button class="toolbar-btn" (click)="rotateRight()" aria-label="Rotate Right" [innerHTML]="icons.rotateRight">
1201
+ </button>
1202
+ }
1203
+ <!-- Zoom -->
1204
+ @if (toolbarConfig().showZoom) {
1205
+ <button class="toolbar-btn" (click)="zoomOut()" aria-label="Zoom Out" [innerHTML]="icons.zoomOut">
1206
+ </button>
1207
+ <button class="toolbar-btn" (click)="zoomIn()" aria-label="Zoom In" [innerHTML]="icons.zoomIn">
1208
+ </button>
1209
+ }
1210
+ </div>
1211
+ }
1212
+ }
1213
+
1214
+ <!-- Thumbnails -->
1215
+ @if (images() && images()!.length > 1 && showThumbnails()) {
1216
+ <div class="thumbnail-strip" (click)="$event.stopPropagation()">
1217
+ @for (img of images(); track img; let i = $index) {
1218
+ <div
1219
+ #thumbRef
1220
+ class="thumbnail-item"
1221
+ [class.active]="i === currentIndex()"
1222
+ (click)="jumpTo(i)"
1223
+ >
1224
+ <img [src]="img" loading="lazy" alt="Thumbnail">
1225
+ </div>
1226
+ }
828
1227
  </div>
829
1228
  }
830
1229
  </div>
@@ -843,8 +1242,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
843
1242
  '(document:keydown.arrowleft)': 'prev()',
844
1243
  '(document:keydown.arrowright)': 'next()',
845
1244
  '(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: [{
1245
+ }, 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"] }]
1246
+ }], 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
1247
  type: HostListener,
849
1248
  args: ['document:keydown.escape']
850
1249
  }], onMouseMove: [{
@@ -852,10 +1251,62 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
852
1251
  args: ['document:mousemove', ['$event']]
853
1252
  }] } });
854
1253
 
1254
+ /**
1255
+ * Directive to open the image preview.
1256
+ * Can be used on any element, but auto-detects `src` on `img` or nested `img`.
1257
+ */
855
1258
  class ImagesPreviewDirective {
1259
+ /**
1260
+ * High resolution image source to display in preview.
1261
+ * If empty, tries to read `src` from host element or first child `img`.
1262
+ */
856
1263
  highResSrc = '';
1264
+ /**
1265
+ * List of images for gallery navigation.
1266
+ * If provided, enables previous/next navigation buttons.
1267
+ */
857
1268
  previewImages = [];
1269
+ /**
1270
+ * Custom template to use for the preview.
1271
+ * If provided, overrides the default viewer UI.
1272
+ */
858
1273
  previewTemplate;
1274
+ /**
1275
+ * Configuration for the toolbar buttons.
1276
+ */
1277
+ toolbarConfig;
1278
+ /**
1279
+ * Optional srcset for the single image.
1280
+ */
1281
+ srcset;
1282
+ /**
1283
+ * List of srcsets corresponding to the `previewImages` array.
1284
+ */
1285
+ /**
1286
+ * List of srcsets corresponding to the `previewImages` array.
1287
+ */
1288
+ srcsets;
1289
+ /**
1290
+ * Whether to show the thumbnail strip.
1291
+ * @default true
1292
+ */
1293
+ showThumbnails = true;
1294
+ /**
1295
+ * Whether to show the toolbar.
1296
+ * @default true
1297
+ */
1298
+ showToolbar = true;
1299
+ /**
1300
+ * Custom template to render in the toolbar (e.g. for download buttons).
1301
+ */
1302
+ toolbarExtensions = null;
1303
+ /**
1304
+ * Type guard helper for strict template type checking.
1305
+ * Allows Angular Language Service to infer types in `previewTemplate`.
1306
+ */
1307
+ static ngTemplateContextGuard(directive, context) {
1308
+ return true;
1309
+ }
859
1310
  componentRef = null;
860
1311
  appRef = inject(ApplicationRef);
861
1312
  injector = inject(EnvironmentInjector);
@@ -878,11 +1329,12 @@ class ImagesPreviewDirective {
878
1329
  }
879
1330
  src = src || '';
880
1331
  if (src) {
881
- this.openPreview(src);
1332
+ const rect = hostEl.getBoundingClientRect();
1333
+ this.openPreview(src, rect);
882
1334
  }
883
1335
  }
884
1336
  cursor = 'pointer';
885
- openPreview(src) {
1337
+ openPreview(src, rect) {
886
1338
  if (!isPlatformBrowser(this.platformId))
887
1339
  return;
888
1340
  // Create Component
@@ -891,6 +1343,7 @@ class ImagesPreviewDirective {
891
1343
  });
892
1344
  // Set Inputs
893
1345
  this.componentRef.setInput('src', src);
1346
+ this.componentRef.setInput('openerRect', rect);
894
1347
  if (this.previewImages.length > 0) {
895
1348
  this.componentRef.setInput('images', this.previewImages);
896
1349
  const index = this.previewImages.indexOf(src);
@@ -899,6 +1352,18 @@ class ImagesPreviewDirective {
899
1352
  if (this.previewTemplate) {
900
1353
  this.componentRef.setInput('customTemplate', this.previewTemplate);
901
1354
  }
1355
+ if (this.toolbarConfig) {
1356
+ this.componentRef.setInput('toolbarConfig', this.toolbarConfig);
1357
+ }
1358
+ if (this.srcset) {
1359
+ this.componentRef.setInput('srcset', this.srcset);
1360
+ }
1361
+ if (this.srcsets) {
1362
+ this.componentRef.setInput('srcsets', this.srcsets);
1363
+ }
1364
+ this.componentRef.setInput('showThumbnails', this.showThumbnails);
1365
+ this.componentRef.setInput('showToolbar', this.showToolbar);
1366
+ this.componentRef.setInput('toolbarExtensions', this.toolbarExtensions);
902
1367
  // Set Callbacks
903
1368
  this.componentRef.instance.closeCallback = () => this.destroyPreview();
904
1369
  // Attach to App
@@ -918,7 +1383,7 @@ class ImagesPreviewDirective {
918
1383
  this.destroyPreview();
919
1384
  }
920
1385
  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 });
1386
+ 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
1387
  }
923
1388
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImport: i0, type: ImagesPreviewDirective, decorators: [{
924
1389
  type: Directive,
@@ -933,6 +1398,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
933
1398
  type: Input
934
1399
  }], previewTemplate: [{
935
1400
  type: Input
1401
+ }], toolbarConfig: [{
1402
+ type: Input
1403
+ }], srcset: [{
1404
+ type: Input
1405
+ }], srcsets: [{
1406
+ type: Input
1407
+ }], showThumbnails: [{
1408
+ type: Input
1409
+ }], showToolbar: [{
1410
+ type: Input
1411
+ }], toolbarExtensions: [{
1412
+ type: Input
936
1413
  }], onClick: [{
937
1414
  type: HostListener,
938
1415
  args: ['click', ['$event']]
@@ -941,6 +1418,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
941
1418
  args: ['style.cursor']
942
1419
  }] } });
943
1420
 
1421
+ class NgImagesPreviewModule {
1422
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.7", ngImport: i0, type: NgImagesPreviewModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
1423
+ static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.0.7", ngImport: i0, type: NgImagesPreviewModule, imports: [ImagesPreviewComponent, ImagesPreviewDirective], exports: [ImagesPreviewComponent, ImagesPreviewDirective] });
1424
+ static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.0.7", ngImport: i0, type: NgImagesPreviewModule, imports: [ImagesPreviewComponent] });
1425
+ }
1426
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImport: i0, type: NgImagesPreviewModule, decorators: [{
1427
+ type: NgModule,
1428
+ args: [{
1429
+ imports: [ImagesPreviewComponent, ImagesPreviewDirective],
1430
+ exports: [ImagesPreviewComponent, ImagesPreviewDirective]
1431
+ }]
1432
+ }] });
1433
+
944
1434
  /*
945
1435
  * Public API Surface of ng-images-preview
946
1436
  */
@@ -949,5 +1439,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.7", ngImpor
949
1439
  * Generated bundle index. Do not edit.
950
1440
  */
951
1441
 
952
- export { ImagesPreviewComponent, ImagesPreviewDirective };
1442
+ export { ImagesPreviewComponent, ImagesPreviewDirective, NgImagesPreviewModule };
953
1443
  //# sourceMappingURL=ng-images-preview.mjs.map