@hoangyell/gallery 1.0.8 → 1.1.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.
package/README.md CHANGED
@@ -28,7 +28,7 @@
28
28
 
29
29
  - **Pyramid layout** — distribute images across rows with `rows` prop
30
30
  - **Two fit modes** — `cover` (default, uniform photo grid) or `contain` (natural aspect, for diagrams and tall portraits)
31
- - **Lightbox** — click to open, keyboard arrows, swipe, ESC to close
31
+ - **Lightbox & pinch-to-zoom** — click to open, smooth multi-touch pinch-to-zoom & pan, double-tap zoom, mouse wheel, keyboard navigation (`+`, `-`, `0`, arrows, ESC)
32
32
  - **Corner rounding** — only the four outer corners of each group are rounded
33
33
  - **Hover zoom** — image scales inside its cell without changing layout
34
34
  - **Accessible** — focus management, ARIA labels, safe-area controls, reduced-motion support
@@ -81,6 +81,8 @@ import { Gallery } from "@hoangyell/gallery";
81
81
  | `orientation` | `"landscape" \| "portrait"` | `"landscape"` | Item aspect ratio hint |
82
82
  | `scale` | `number` | `1` | Gallery width scale (0–1) |
83
83
  | `fit` | `ObjectFit` | `"cover"` | Default CSS object-fit — see Fit modes below |
84
+ | `zoom` | `boolean` | `true` | Enable pinch-to-zoom, pan, double-tap, and wheel zoom |
85
+ | `zoomControls` | `boolean` | `false` | Show visible zoom in / zoom out / reset buttons in lightbox |
84
86
  | `class` | `string` | — | Extra wrapper class |
85
87
 
86
88
  ## Fit modes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoangyell/gallery",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "Astro image gallery with lightbox, pyramid multi-row layout, and accessible controls.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/Gallery.astro CHANGED
@@ -29,6 +29,10 @@ export interface Props {
29
29
  scale?: number;
30
30
  /** Global object-fit for all items unless overridden per-item. */
31
31
  fit?: ObjectFit;
32
+ /** Enable zoom gestures (pinch, double-tap, pan, wheel) in lightbox. Defaults to true. */
33
+ zoom?: boolean;
34
+ /** Show visible zoom in/out/reset buttons in the lightbox toolbar. Defaults to false. */
35
+ zoomControls?: boolean;
32
36
  }
33
37
 
34
38
  const {
@@ -37,6 +41,8 @@ const {
37
41
  orientation = "landscape",
38
42
  scale = 1,
39
43
  fit = "cover",
44
+ zoom = true,
45
+ zoomControls = false,
40
46
  } = Astro.props;
41
47
 
42
48
  const rawItems = Array.isArray(Astro.props.items) ? Astro.props.items : [];
@@ -101,6 +107,8 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
101
107
  data-hy-gallery
102
108
  data-orientation={orientation}
103
109
  data-fit={fit}
110
+ data-zoom={zoom ? "true" : "false"}
111
+ data-zoom-controls={zoomControls ? "true" : "false"}
104
112
  style={scale !== 1 ? `width: ${scale * 100}%; margin: 2rem auto;` : undefined}
105
113
  >
106
114
  {groupedItems.map((group, rowIdx) => {
@@ -340,6 +348,9 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
340
348
  opacity: 0;
341
349
  transition: opacity 0.3s ease;
342
350
  cursor: zoom-out;
351
+ touch-action: none;
352
+ user-select: none;
353
+ -webkit-user-select: none;
343
354
  }
344
355
 
345
356
  :global(.hy-gallery-overlay.is-active) {
@@ -353,6 +364,8 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
353
364
  display: flex;
354
365
  align-items: center;
355
366
  justify-content: center;
367
+ overflow: visible;
368
+ touch-action: none;
356
369
  }
357
370
 
358
371
  :global(.hy-gallery-overlay-img) {
@@ -362,15 +375,22 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
362
375
  border-radius: 0.5rem;
363
376
  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
364
377
  border: 1px solid var(--hy-gallery-border);
365
- cursor: default;
378
+ cursor: zoom-in;
366
379
  user-select: none;
367
- transform: scale(0.92);
380
+ -webkit-user-select: none;
381
+ -webkit-user-drag: none;
382
+ touch-action: none;
383
+ transform-origin: center center;
384
+ will-change: transform;
368
385
  transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
369
- will-change: transform, opacity;
370
386
  }
371
387
 
372
- :global(.hy-gallery-overlay.is-active .hy-gallery-overlay-img) {
373
- transform: scale(1);
388
+ :global(.hy-gallery-overlay-img.is-zoomed) {
389
+ cursor: grab;
390
+ }
391
+
392
+ :global(.hy-gallery-overlay-img.is-dragging) {
393
+ cursor: grabbing !important;
374
394
  }
375
395
 
376
396
  :global(.hy-gallery-slide-anim) {
@@ -412,10 +432,18 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
412
432
  backdrop-filter: blur(4px);
413
433
  }
414
434
 
415
- :global(.hy-gallery-close) {
435
+ :global(.hy-gallery-controls) {
416
436
  position: absolute;
417
437
  top: max(env(safe-area-inset-top, 0px), 16px);
418
438
  right: max(env(safe-area-inset-right, 0px), 16px);
439
+ display: flex;
440
+ align-items: center;
441
+ gap: 8px;
442
+ z-index: 10;
443
+ }
444
+
445
+ :global(.hy-gallery-btn),
446
+ :global(.hy-gallery-close) {
419
447
  width: 2.5rem;
420
448
  height: 2.5rem;
421
449
  display: flex;
@@ -426,10 +454,41 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
426
454
  background: var(--hy-gallery-control-bg);
427
455
  color: var(--hy-gallery-text);
428
456
  cursor: pointer;
429
- transition: transform 0.2s ease;
457
+ transition: transform 0.2s ease, color 0.2s ease, opacity 0.2s ease;
430
458
  z-index: 10;
431
459
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
432
460
  backdrop-filter: blur(4px);
461
+ padding: 0;
462
+ }
463
+
464
+ :global(.hy-gallery-btn:hover),
465
+ :global(.hy-gallery-btn:focus-visible),
466
+ :global(.hy-gallery-close:hover),
467
+ :global(.hy-gallery-close:focus-visible) {
468
+ transform: scale(1.08);
469
+ color: var(--hy-gallery-accent);
470
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
471
+ }
472
+
473
+ :global(.hy-gallery-btn:disabled),
474
+ :global(.hy-gallery-btn[aria-disabled="true"]) {
475
+ opacity: 0.35;
476
+ cursor: not-allowed;
477
+ transform: none !important;
478
+ pointer-events: none;
479
+ }
480
+
481
+ :global(.hy-gallery-btn:focus),
482
+ :global(.hy-gallery-close:focus),
483
+ :global(.hy-gallery-nav:focus) {
484
+ outline: none;
485
+ }
486
+
487
+ :global(.hy-gallery-btn:focus-visible),
488
+ :global(.hy-gallery-close:focus-visible),
489
+ :global(.hy-gallery-nav:focus-visible) {
490
+ outline: 2px solid var(--hy-gallery-accent);
491
+ outline-offset: 2px;
433
492
  }
434
493
 
435
494
  :global(.hy-gallery-nav) {
@@ -460,24 +519,34 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
460
519
  right: max(env(safe-area-inset-right, 0px), 16px);
461
520
  }
462
521
 
463
- :global(.hy-gallery-close:hover),
464
522
  :global(.hy-gallery-nav:hover),
465
- :global(.hy-gallery-close:focus-visible),
466
523
  :global(.hy-gallery-nav:focus-visible) {
467
- transform: scale(1.1);
524
+ transform: translateY(-50%) scale(1.1);
468
525
  color: var(--hy-gallery-accent);
469
526
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
470
527
  }
471
528
 
472
- :global(.hy-gallery-nav:hover),
473
- :global(.hy-gallery-nav:focus-visible) {
474
- transform: translateY(-50%) scale(1.1);
475
- }
476
-
477
529
  @media (max-width: 639px) {
530
+ :global(.hy-gallery-controls) {
531
+ top: max(env(safe-area-inset-top, 0px), 8px);
532
+ right: max(env(safe-area-inset-right, 0px), 8px);
533
+ gap: 6px;
534
+ }
535
+ :global(.hy-gallery-counter) {
536
+ top: max(env(safe-area-inset-top, 0px), 8px);
537
+ left: max(env(safe-area-inset-left, 0px), 8px);
538
+ width: 2.25rem;
539
+ height: 2.25rem;
540
+ font-size: 0.625rem;
541
+ }
542
+ :global(.hy-gallery-btn),
543
+ :global(.hy-gallery-close) {
544
+ width: 2.25rem;
545
+ height: 2.25rem;
546
+ }
478
547
  :global(.hy-gallery-nav) {
479
- width: 2.5rem;
480
- height: 2.5rem;
548
+ width: 2.25rem;
549
+ height: 2.25rem;
481
550
  }
482
551
  :global(.hy-gallery-nav-prev) {
483
552
  left: max(env(safe-area-inset-left, 0px), 8px);
@@ -497,9 +566,43 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
497
566
  counterEl: HTMLElement | null;
498
567
  prevBtn: HTMLButtonElement | null;
499
568
  nextBtn: HTMLButtonElement | null;
569
+ zoomControls: HTMLElement | null;
570
+ zoomInBtn: HTMLButtonElement | null;
571
+ zoomOutBtn: HTMLButtonElement | null;
572
+ zoomResetBtn: HTMLButtonElement | null;
573
+ closeBtn: HTMLButtonElement | null;
500
574
  currentIndex: number;
575
+ zoomEnabled: boolean;
576
+ zoomControlsEnabled: boolean;
577
+
578
+ // Zoom & pan state
579
+ scale: number;
580
+ translateX: number;
581
+ translateY: number;
582
+
583
+ // Touch tracking
501
584
  touchStartX: number;
502
585
  touchStartY: number;
586
+ touchLastX: number;
587
+ touchLastY: number;
588
+ isPinching: boolean;
589
+ isDragging: boolean;
590
+ pinchStartDist: number;
591
+ pinchStartScale: number;
592
+ pinchStartCenter: { x: number; y: number };
593
+ pinchStartTranslate: { x: number; y: number };
594
+ lastTapTime: number;
595
+
596
+ // Mouse tracking
597
+ isMouseDown: boolean;
598
+ mouseStartX: number;
599
+ mouseStartY: number;
600
+ mouseLastX: number;
601
+ mouseLastY: number;
602
+ hasMouseDragged: boolean;
603
+ isAnimating: boolean;
604
+
605
+ // Body scroll lock state
503
606
  lockedScrollY: number;
504
607
  previousBodyOverflow: string;
505
608
  previousBodyPosition: string;
@@ -510,6 +613,8 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
510
613
 
511
614
  constructor(el: HTMLElement) {
512
615
  this.gallery = el;
616
+ this.zoomEnabled = el.dataset.zoom !== "false";
617
+ this.zoomControlsEnabled = el.dataset.zoomControls === "true";
513
618
  this.items = Array.from(
514
619
  el.querySelectorAll<HTMLButtonElement>(".hy-gallery-item"),
515
620
  );
@@ -518,9 +623,37 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
518
623
  this.counterEl = null;
519
624
  this.prevBtn = null;
520
625
  this.nextBtn = null;
626
+ this.zoomControls = null;
627
+ this.zoomInBtn = null;
628
+ this.zoomOutBtn = null;
629
+ this.zoomResetBtn = null;
630
+ this.closeBtn = null;
521
631
  this.currentIndex = -1;
632
+
633
+ this.scale = 1;
634
+ this.translateX = 0;
635
+ this.translateY = 0;
636
+
522
637
  this.touchStartX = 0;
523
638
  this.touchStartY = 0;
639
+ this.touchLastX = 0;
640
+ this.touchLastY = 0;
641
+ this.isPinching = false;
642
+ this.isDragging = false;
643
+ this.pinchStartDist = 0;
644
+ this.pinchStartScale = 1;
645
+ this.pinchStartCenter = { x: 0, y: 0 };
646
+ this.pinchStartTranslate = { x: 0, y: 0 };
647
+ this.lastTapTime = 0;
648
+
649
+ this.isMouseDown = false;
650
+ this.mouseStartX = 0;
651
+ this.mouseStartY = 0;
652
+ this.mouseLastX = 0;
653
+ this.mouseLastY = 0;
654
+ this.hasMouseDragged = false;
655
+ this.isAnimating = false;
656
+
524
657
  this.lockedScrollY = -1;
525
658
  this.previousBodyOverflow = "";
526
659
  this.previousBodyPosition = "";
@@ -544,15 +677,173 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
544
677
  });
545
678
  };
546
679
 
680
+ getContentCenter = () => {
681
+ return {
682
+ x: window.innerWidth / 2,
683
+ y: window.innerHeight / 2,
684
+ };
685
+ };
686
+
687
+ applyTransform = (withTransition = false) => {
688
+ if (!this.imgEl) return;
689
+ if (withTransition) {
690
+ this.imgEl.style.transition =
691
+ "transform 0.28s cubic-bezier(0.16, 1, 0.3, 1)";
692
+ } else {
693
+ this.imgEl.style.transition = "none";
694
+ }
695
+ this.imgEl.style.transform = `translate3d(${this.translateX}px, ${this.translateY}px, 0px) scale(${this.scale})`;
696
+ };
697
+
698
+ animateTo = (targetScale: number, targetTx: number, targetTy: number) => {
699
+ this.scale = targetScale;
700
+ this.translateX = targetTx;
701
+ this.translateY = targetTy;
702
+ this.isAnimating = true;
703
+ this.applyTransform(true);
704
+ this.updateZoomUI();
705
+ window.setTimeout(() => {
706
+ this.isAnimating = false;
707
+ if (this.imgEl && !this.isPinching && !this.isMouseDown) {
708
+ this.imgEl.style.transition = "";
709
+ }
710
+ }, 300);
711
+ };
712
+
713
+ resetZoom = (animated = false) => {
714
+ if (animated) {
715
+ this.animateTo(1, 0, 0);
716
+ } else {
717
+ this.scale = 1;
718
+ this.translateX = 0;
719
+ this.translateY = 0;
720
+ this.applyTransform(false);
721
+ this.updateZoomUI();
722
+ }
723
+ };
724
+
725
+ clampToBounds = (animate = false) => {
726
+ if (!this.imgEl) return;
727
+ if (this.scale <= 1.01) {
728
+ if (animate) {
729
+ this.animateTo(1, 0, 0);
730
+ } else {
731
+ this.resetZoom(false);
732
+ }
733
+ return;
734
+ }
735
+
736
+ const baseW = this.imgEl.offsetWidth || this.imgEl.naturalWidth || 800;
737
+ const baseH = this.imgEl.offsetHeight || this.imgEl.naturalHeight || 600;
738
+ const scaledW = baseW * this.scale;
739
+ const scaledH = baseH * this.scale;
740
+
741
+ const vpW = window.innerWidth;
742
+ const vpH = window.innerHeight;
743
+
744
+ const maxTx = Math.max(0, (scaledW - vpW) / 2 + 24);
745
+ const maxTy = Math.max(0, (scaledH - vpH) / 2 + 24);
746
+
747
+ const clampedTx = Math.min(maxTx, Math.max(-maxTx, this.translateX));
748
+ const clampedTy = Math.min(maxTy, Math.max(-maxTy, this.translateY));
749
+
750
+ if (animate) {
751
+ this.animateTo(this.scale, clampedTx, clampedTy);
752
+ } else {
753
+ this.translateX = clampedTx;
754
+ this.translateY = clampedTy;
755
+ this.applyTransform(false);
756
+ this.updateZoomUI();
757
+ }
758
+ };
759
+
760
+ zoomBy = (factor: number) => {
761
+ if (!this.zoomEnabled) return;
762
+ const newScale = Math.min(5.0, Math.max(1.0, this.scale * factor));
763
+ if (newScale <= 1.01) {
764
+ this.resetZoom(true);
765
+ return;
766
+ }
767
+ const ratio = newScale / this.scale;
768
+ this.scale = newScale;
769
+ this.translateX = this.translateX * ratio;
770
+ this.translateY = this.translateY * ratio;
771
+ this.clampToBounds(true);
772
+ };
773
+
774
+ panBy = (dx: number, dy: number) => {
775
+ this.translateX += dx;
776
+ this.translateY += dy;
777
+ this.clampToBounds(true);
778
+ };
779
+
780
+ handleDoubleTap = (clientX: number, clientY: number) => {
781
+ if (!this.zoomEnabled) return;
782
+ if (this.scale > 1.15) {
783
+ this.resetZoom(true);
784
+ } else {
785
+ const targetScale = 2.5;
786
+ const center = this.getContentCenter();
787
+ const targetTx = clientX - center.x - targetScale * (clientX - center.x);
788
+ const targetTy = clientY - center.y - targetScale * (clientY - center.y);
789
+ this.scale = targetScale;
790
+ this.translateX = targetTx;
791
+ this.translateY = targetTy;
792
+ this.clampToBounds(true);
793
+ }
794
+ };
795
+
796
+ updateZoomUI = () => {
797
+ if (!this.zoomEnabled) return;
798
+ if (this.zoomOutBtn) {
799
+ const disabled = this.scale <= 1.01;
800
+ this.zoomOutBtn.disabled = disabled;
801
+ this.zoomOutBtn.setAttribute("aria-disabled", disabled ? "true" : "false");
802
+ }
803
+ if (this.zoomInBtn) {
804
+ const disabled = this.scale >= 4.95;
805
+ this.zoomInBtn.disabled = disabled;
806
+ this.zoomInBtn.setAttribute("aria-disabled", disabled ? "true" : "false");
807
+ }
808
+ if (this.zoomResetBtn) {
809
+ const isZoomed = this.scale > 1.01;
810
+ this.zoomResetBtn.disabled = !isZoomed;
811
+ this.zoomResetBtn.setAttribute("aria-disabled", !isZoomed ? "true" : "false");
812
+ const title = isZoomed
813
+ ? `Reset zoom (${Math.round(this.scale * 100)}%)`
814
+ : "Reset zoom";
815
+ this.zoomResetBtn.title = title;
816
+ this.zoomResetBtn.setAttribute("aria-label", title);
817
+ }
818
+ if (this.imgEl) {
819
+ if (this.scale > 1.01) {
820
+ this.imgEl.classList.add("is-zoomed");
821
+ this.imgEl.style.cursor = this.isMouseDown ? "grabbing" : "grab";
822
+ } else {
823
+ this.imgEl.classList.remove("is-zoomed", "is-dragging");
824
+ this.imgEl.style.cursor = "zoom-in";
825
+ }
826
+ }
827
+ };
828
+
547
829
  forceClose = () => {
548
830
  if (this.overlay) {
831
+ window.removeEventListener("mousemove", this.handleMouseMove);
832
+ window.removeEventListener("mouseup", this.handleMouseUp);
833
+ window.removeEventListener("resize", this.handleResize);
834
+ document.removeEventListener("keydown", this.handleKey);
835
+ document.removeEventListener("gesturestart", this.preventZoom);
836
+
549
837
  this.overlay.remove();
550
838
  this.overlay = null;
551
839
  this.imgEl = null;
552
840
  this.counterEl = null;
841
+ this.zoomControls = null;
842
+ this.zoomInBtn = null;
843
+ this.zoomOutBtn = null;
844
+ this.zoomResetBtn = null;
845
+ this.closeBtn = null;
553
846
 
554
- document.removeEventListener("keydown", this.handleKey);
555
- document.removeEventListener("gesturestart", this.preventZoom);
556
847
  const main = document.querySelector("main");
557
848
  if (main) main.removeAttribute("inert");
558
849
  }
@@ -570,6 +861,7 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
570
861
 
571
862
  this.imgEl.src = src;
572
863
  this.imgEl.alt = alt;
864
+ this.resetZoom(false);
573
865
  this.updateCounter();
574
866
  this.updateNavButtons();
575
867
  this.preload(index + 1);
@@ -599,16 +891,70 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
599
891
  this.overlay.setAttribute("aria-modal", "true");
600
892
  this.overlay.setAttribute("aria-label", "Image viewer");
601
893
 
602
- const closeBtn = document.createElement("button");
603
- closeBtn.className = "hy-gallery-close";
604
- closeBtn.setAttribute("aria-label", "Close");
605
- closeBtn.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
606
- closeBtn.addEventListener("click", this.close);
894
+ // Counter (top left)
895
+ this.counterEl = document.createElement("div");
896
+ this.counterEl.className = "hy-gallery-counter";
897
+ this.counterEl.setAttribute("aria-hidden", "true");
898
+
899
+ // Controls (top right)
900
+ this.zoomControls = document.createElement("div");
901
+ this.zoomControls.className = "hy-gallery-controls";
902
+
903
+ if (this.zoomEnabled && this.zoomControlsEnabled) {
904
+ this.zoomOutBtn = document.createElement("button");
905
+ this.zoomOutBtn.className = "hy-gallery-btn hy-gallery-zoom-out";
906
+ this.zoomOutBtn.setAttribute("type", "button");
907
+ this.zoomOutBtn.setAttribute("aria-label", "Zoom out");
908
+ this.zoomOutBtn.title = "Zoom out (-)";
909
+ this.zoomOutBtn.disabled = true;
910
+ this.zoomOutBtn.setAttribute("aria-disabled", "true");
911
+ this.zoomOutBtn.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`;
912
+ this.zoomOutBtn.addEventListener("click", (e) => {
913
+ e.stopPropagation();
914
+ this.zoomBy(1 / 1.5);
915
+ });
916
+
917
+ this.zoomInBtn = document.createElement("button");
918
+ this.zoomInBtn.className = "hy-gallery-btn hy-gallery-zoom-in";
919
+ this.zoomInBtn.setAttribute("type", "button");
920
+ this.zoomInBtn.setAttribute("aria-label", "Zoom in");
921
+ this.zoomInBtn.title = "Zoom in (+)";
922
+ this.zoomInBtn.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`;
923
+ this.zoomInBtn.addEventListener("click", (e) => {
924
+ e.stopPropagation();
925
+ this.zoomBy(1.5);
926
+ });
927
+
928
+ this.zoomResetBtn = document.createElement("button");
929
+ this.zoomResetBtn.className = "hy-gallery-btn hy-gallery-zoom-reset";
930
+ this.zoomResetBtn.setAttribute("type", "button");
931
+ this.zoomResetBtn.setAttribute("aria-label", "Reset zoom");
932
+ this.zoomResetBtn.title = "Reset zoom (0)";
933
+ this.zoomResetBtn.disabled = true;
934
+ this.zoomResetBtn.setAttribute("aria-disabled", "true");
935
+ this.zoomResetBtn.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>`;
936
+ this.zoomResetBtn.addEventListener("click", (e) => {
937
+ e.stopPropagation();
938
+ this.resetZoom(true);
939
+ });
940
+
941
+ this.zoomControls.append(this.zoomOutBtn, this.zoomInBtn, this.zoomResetBtn);
942
+ }
943
+
944
+ this.closeBtn = document.createElement("button");
945
+ this.closeBtn.className = "hy-gallery-close";
946
+ this.closeBtn.setAttribute("type", "button");
947
+ this.closeBtn.setAttribute("aria-label", "Close");
948
+ this.closeBtn.title = "Close (ESC)";
949
+ this.closeBtn.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
950
+ this.closeBtn.addEventListener("click", this.close);
951
+ this.zoomControls.appendChild(this.closeBtn);
607
952
 
608
953
  this.prevBtn = document.createElement("button");
609
954
  this.prevBtn.className = "hy-gallery-nav hy-gallery-nav-prev";
955
+ this.prevBtn.setAttribute("type", "button");
610
956
  this.prevBtn.setAttribute("aria-label", "Previous image");
611
- this.prevBtn.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>`;
957
+ this.prevBtn.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>`;
612
958
  this.prevBtn.addEventListener("click", (e) => {
613
959
  e.stopPropagation();
614
960
  this.navigate(-1);
@@ -616,8 +962,9 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
616
962
 
617
963
  this.nextBtn = document.createElement("button");
618
964
  this.nextBtn.className = "hy-gallery-nav hy-gallery-nav-next";
965
+ this.nextBtn.setAttribute("type", "button");
619
966
  this.nextBtn.setAttribute("aria-label", "Next image");
620
- this.nextBtn.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`;
967
+ this.nextBtn.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>`;
621
968
  this.nextBtn.addEventListener("click", (e) => {
622
969
  e.stopPropagation();
623
970
  this.navigate(1);
@@ -630,12 +977,14 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
630
977
  this.imgEl.className = "hy-gallery-overlay-img";
631
978
  this.imgEl.setAttribute("decoding", "sync");
632
979
 
633
- this.counterEl = document.createElement("div");
634
- this.counterEl.className = "hy-gallery-counter";
635
- this.counterEl.setAttribute("aria-hidden", "true");
636
-
637
980
  content.appendChild(this.imgEl);
638
- this.overlay.append(closeBtn, this.prevBtn, this.nextBtn, content, this.counterEl);
981
+ this.overlay.append(
982
+ this.counterEl,
983
+ this.zoomControls,
984
+ this.prevBtn,
985
+ this.nextBtn,
986
+ content,
987
+ );
639
988
 
640
989
  if (this.items.length <= 1) {
641
990
  this.prevBtn.style.display = "none";
@@ -644,12 +993,24 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
644
993
  }
645
994
 
646
995
  this.overlay.addEventListener("click", this.handleOverlayClick);
996
+ this.overlay.addEventListener("wheel", this.handleWheel, { passive: false });
647
997
  this.overlay.addEventListener("touchstart", this.handleTouchStart, {
648
- passive: true,
998
+ passive: false,
999
+ });
1000
+ this.overlay.addEventListener("touchmove", this.handleTouchMove, {
1001
+ passive: false,
649
1002
  });
650
1003
  this.overlay.addEventListener("touchend", this.handleTouchEnd, {
651
- passive: true,
1004
+ passive: false,
1005
+ });
1006
+ this.overlay.addEventListener("touchcancel", this.handleTouchEnd, {
1007
+ passive: false,
652
1008
  });
1009
+ this.overlay.addEventListener("mousedown", this.handleMouseDown);
1010
+ this.overlay.addEventListener("dblclick", this.handleDblClick);
1011
+ window.addEventListener("mousemove", this.handleMouseMove);
1012
+ window.addEventListener("mouseup", this.handleMouseUp);
1013
+ window.addEventListener("resize", this.handleResize);
653
1014
  document.addEventListener("keydown", this.handleKey);
654
1015
  };
655
1016
 
@@ -657,20 +1018,31 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
657
1018
  if (!this.overlay) return;
658
1019
 
659
1020
  this.overlay.classList.remove("is-active");
1021
+ this.resetZoom(true);
660
1022
 
661
1023
  setTimeout(() => {
662
- this.overlay?.parentNode?.removeChild(this.overlay);
663
- this.overlay = null;
664
- this.imgEl = null;
665
- this.counterEl = null;
1024
+ if (this.overlay) {
1025
+ window.removeEventListener("mousemove", this.handleMouseMove);
1026
+ window.removeEventListener("mouseup", this.handleMouseUp);
1027
+ window.removeEventListener("resize", this.handleResize);
1028
+ document.removeEventListener("keydown", this.handleKey);
1029
+ document.removeEventListener("gesturestart", this.preventZoom);
1030
+
1031
+ this.overlay.parentNode?.removeChild(this.overlay);
1032
+ this.overlay = null;
1033
+ this.imgEl = null;
1034
+ this.counterEl = null;
1035
+ this.zoomControls = null;
1036
+ this.zoomInBtn = null;
1037
+ this.zoomOutBtn = null;
1038
+ this.zoomResetBtn = null;
1039
+ this.closeBtn = null;
1040
+ }
666
1041
 
667
1042
  this.unlockBackgroundScroll();
668
1043
  const main = document.querySelector("main");
669
1044
  if (main) main.removeAttribute("inert");
670
1045
 
671
- document.removeEventListener("keydown", this.handleKey);
672
- document.removeEventListener("gesturestart", this.preventZoom);
673
-
674
1046
  if (this.currentIndex >= 0 && this.currentIndex < this.items.length) {
675
1047
  this.items[this.currentIndex].focus();
676
1048
  }
@@ -720,6 +1092,8 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
720
1092
  const content = this.overlay?.querySelector(".hy-gallery-overlay-content");
721
1093
  if (!content || !this.imgEl) return;
722
1094
 
1095
+ this.resetZoom(false);
1096
+
723
1097
  const oldImg = this.imgEl;
724
1098
  this.imgEl = document.createElement("img");
725
1099
  this.imgEl.className = "hy-gallery-overlay-img hy-gallery-slide-anim";
@@ -733,8 +1107,17 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
733
1107
 
734
1108
  content.replaceChild(this.imgEl, oldImg);
735
1109
 
1110
+ this.imgEl.addEventListener(
1111
+ "animationend",
1112
+ () => {
1113
+ this.imgEl?.classList.remove("hy-gallery-slide-anim");
1114
+ },
1115
+ { once: true },
1116
+ );
1117
+
736
1118
  this.updateCounter();
737
1119
  this.updateNavButtons();
1120
+ this.updateZoomUI();
738
1121
  this.preload(next + dir);
739
1122
  };
740
1123
 
@@ -759,6 +1142,10 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
759
1142
  };
760
1143
 
761
1144
  handleOverlayClick = (e: MouseEvent) => {
1145
+ if (this.hasMouseDragged) {
1146
+ this.hasMouseDragged = false;
1147
+ return;
1148
+ }
762
1149
  const target = e.target as HTMLElement;
763
1150
  if (
764
1151
  target.classList.contains("hy-gallery-overlay") ||
@@ -769,22 +1156,270 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
769
1156
  };
770
1157
 
771
1158
  handleKey = (e: KeyboardEvent) => {
772
- if (e.key === "Escape") this.close();
773
- else if (e.key === "ArrowLeft") this.navigate(-1);
774
- else if (e.key === "ArrowRight") this.navigate(1);
1159
+ if (e.key === "Escape") {
1160
+ if (this.scale > 1.05) {
1161
+ this.resetZoom(true);
1162
+ } else {
1163
+ this.close();
1164
+ }
1165
+ } else if (e.key === "+" || e.key === "=") {
1166
+ this.zoomBy(1.3);
1167
+ } else if (e.key === "-" || e.key === "_") {
1168
+ this.zoomBy(1 / 1.3);
1169
+ } else if (e.key === "0") {
1170
+ this.resetZoom(true);
1171
+ } else if (e.key === "ArrowLeft") {
1172
+ if (this.scale > 1.05) {
1173
+ this.panBy(60, 0);
1174
+ } else {
1175
+ this.navigate(-1);
1176
+ }
1177
+ } else if (e.key === "ArrowRight") {
1178
+ if (this.scale > 1.05) {
1179
+ this.panBy(-60, 0);
1180
+ } else {
1181
+ this.navigate(1);
1182
+ }
1183
+ } else if (e.key === "ArrowUp" && this.scale > 1.05) {
1184
+ this.panBy(0, 60);
1185
+ } else if (e.key === "ArrowDown" && this.scale > 1.05) {
1186
+ this.panBy(0, -60);
1187
+ }
775
1188
  };
776
1189
 
777
1190
  handleTouchStart = (e: TouchEvent) => {
778
- this.touchStartX = e.changedTouches[0].screenX;
779
- this.touchStartY = e.changedTouches[0].screenY;
1191
+ const target = e.target as HTMLElement;
1192
+ if (target.closest(".hy-gallery-btn, .hy-gallery-close, .hy-gallery-nav")) {
1193
+ return;
1194
+ }
1195
+
1196
+ if (e.touches.length === 1) {
1197
+ this.touchStartX = e.touches[0].clientX;
1198
+ this.touchStartY = e.touches[0].clientY;
1199
+ this.touchLastX = e.touches[0].clientX;
1200
+ this.touchLastY = e.touches[0].clientY;
1201
+ this.isDragging = false;
1202
+ this.isPinching = false;
1203
+ } else if (e.touches.length === 2 && this.zoomEnabled) {
1204
+ this.isPinching = true;
1205
+ this.isDragging = false;
1206
+ const t1 = e.touches[0];
1207
+ const t2 = e.touches[1];
1208
+ this.pinchStartDist = Math.hypot(
1209
+ t2.clientX - t1.clientX,
1210
+ t2.clientY - t1.clientY,
1211
+ );
1212
+ this.pinchStartScale = this.scale;
1213
+ this.pinchStartCenter = {
1214
+ x: (t1.clientX + t2.clientX) / 2,
1215
+ y: (t1.clientY + t2.clientY) / 2,
1216
+ };
1217
+ this.pinchStartTranslate = { x: this.translateX, y: this.translateY };
1218
+ this.applyTransform(false);
1219
+ }
1220
+ };
1221
+
1222
+ handleTouchMove = (e: TouchEvent) => {
1223
+ if (this.isPinching && e.touches.length >= 2) {
1224
+ e.preventDefault();
1225
+ const t1 = e.touches[0];
1226
+ const t2 = e.touches[1];
1227
+ const currDist = Math.hypot(
1228
+ t2.clientX - t1.clientX,
1229
+ t2.clientY - t1.clientY,
1230
+ );
1231
+ const ratio = currDist / (this.pinchStartDist || 1);
1232
+ const rawScale = this.pinchStartScale * ratio;
1233
+ const clampedScale = Math.min(5.5, Math.max(0.75, rawScale));
1234
+
1235
+ const currCenter = {
1236
+ x: (t1.clientX + t2.clientX) / 2,
1237
+ y: (t1.clientY + t2.clientY) / 2,
1238
+ };
1239
+ const center = this.getContentCenter();
1240
+
1241
+ const T_x =
1242
+ currCenter.x -
1243
+ center.x -
1244
+ (clampedScale / this.pinchStartScale) *
1245
+ (this.pinchStartCenter.x - center.x - this.pinchStartTranslate.x);
1246
+ const T_y =
1247
+ currCenter.y -
1248
+ center.y -
1249
+ (clampedScale / this.pinchStartScale) *
1250
+ (this.pinchStartCenter.y - center.y - this.pinchStartTranslate.y);
1251
+
1252
+ this.scale = clampedScale;
1253
+ this.translateX = T_x;
1254
+ this.translateY = T_y;
1255
+ this.applyTransform(false);
1256
+ this.updateZoomUI();
1257
+ } else if (!this.isPinching && e.touches.length === 1) {
1258
+ const touch = e.touches[0];
1259
+ if (this.scale > 1.01) {
1260
+ e.preventDefault();
1261
+ const dx = touch.clientX - this.touchLastX;
1262
+ const dy = touch.clientY - this.touchLastY;
1263
+ this.touchLastX = touch.clientX;
1264
+ this.touchLastY = touch.clientY;
1265
+ this.translateX += dx;
1266
+ this.translateY += dy;
1267
+ this.isDragging = true;
1268
+ this.applyTransform(false);
1269
+ }
1270
+ }
780
1271
  };
781
1272
 
782
1273
  handleTouchEnd = (e: TouchEvent) => {
783
- const diffX = e.changedTouches[0].screenX - this.touchStartX;
784
- const diffY = e.changedTouches[0].screenY - this.touchStartY;
785
- if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
786
- if (diffX > 0) this.navigate(-1);
787
- else this.navigate(1);
1274
+ if (this.isPinching) {
1275
+ if (e.touches.length < 2) {
1276
+ this.isPinching = false;
1277
+ if (e.touches.length === 1) {
1278
+ this.touchLastX = e.touches[0].clientX;
1279
+ this.touchLastY = e.touches[0].clientY;
1280
+ this.touchStartX = e.touches[0].clientX;
1281
+ this.touchStartY = e.touches[0].clientY;
1282
+ }
1283
+ if (this.scale < 1.0) {
1284
+ this.resetZoom(true);
1285
+ } else if (this.scale > 5.0) {
1286
+ this.scale = 5.0;
1287
+ this.clampToBounds(true);
1288
+ } else {
1289
+ this.clampToBounds(true);
1290
+ }
1291
+ }
1292
+ return;
1293
+ }
1294
+
1295
+ if (e.changedTouches.length === 1) {
1296
+ const touch = e.changedTouches[0];
1297
+ const tapDist = Math.hypot(
1298
+ touch.clientX - this.touchStartX,
1299
+ touch.clientY - this.touchStartY,
1300
+ );
1301
+
1302
+ if (tapDist < 15 && !this.isDragging) {
1303
+ const now = Date.now();
1304
+ if (now - this.lastTapTime < 300) {
1305
+ this.handleDoubleTap(touch.clientX, touch.clientY);
1306
+ this.lastTapTime = 0;
1307
+ return;
1308
+ }
1309
+ this.lastTapTime = now;
1310
+ }
1311
+
1312
+ if (this.scale > 1.01) {
1313
+ this.clampToBounds(true);
1314
+ } else {
1315
+ const diffX = touch.clientX - this.touchStartX;
1316
+ const diffY = touch.clientY - this.touchStartY;
1317
+ if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
1318
+ if (diffX > 0) this.navigate(-1);
1319
+ else this.navigate(1);
1320
+ }
1321
+ }
1322
+ }
1323
+ };
1324
+
1325
+ handleWheel = (e: WheelEvent) => {
1326
+ if (!this.overlay || !this.imgEl || !this.zoomEnabled) return;
1327
+ e.preventDefault();
1328
+ const isPinch = e.ctrlKey;
1329
+ const factor = isPinch
1330
+ ? Math.exp(-e.deltaY * 0.015)
1331
+ : e.deltaY < 0
1332
+ ? 1.2
1333
+ : 0.833;
1334
+
1335
+ const targetScale = Math.min(5.0, Math.max(1.0, this.scale * factor));
1336
+ if (Math.abs(targetScale - this.scale) < 0.001) return;
1337
+
1338
+ if (targetScale <= 1.01) {
1339
+ this.resetZoom(true);
1340
+ return;
1341
+ }
1342
+
1343
+ const center = this.getContentCenter();
1344
+ const T_x =
1345
+ e.clientX -
1346
+ center.x -
1347
+ (targetScale / this.scale) * (e.clientX - center.x - this.translateX);
1348
+ const T_y =
1349
+ e.clientY -
1350
+ center.y -
1351
+ (targetScale / this.scale) * (e.clientY - center.y - this.translateY);
1352
+
1353
+ this.scale = targetScale;
1354
+ this.translateX = T_x;
1355
+ this.translateY = T_y;
1356
+ this.clampToBounds(false);
1357
+ this.updateZoomUI();
1358
+ };
1359
+
1360
+ handleMouseDown = (e: MouseEvent) => {
1361
+ const target = e.target as HTMLElement;
1362
+ if (target.closest(".hy-gallery-btn, .hy-gallery-close, .hy-gallery-nav")) {
1363
+ return;
1364
+ }
1365
+ if (e.button !== 0) return;
1366
+ if (this.scale <= 1.01) return;
1367
+
1368
+ this.isMouseDown = true;
1369
+ this.mouseStartX = e.clientX;
1370
+ this.mouseStartY = e.clientY;
1371
+ this.mouseLastX = e.clientX;
1372
+ this.mouseLastY = e.clientY;
1373
+ this.hasMouseDragged = false;
1374
+ if (this.imgEl) {
1375
+ this.imgEl.classList.add("is-dragging");
1376
+ this.imgEl.style.cursor = "grabbing";
1377
+ this.imgEl.style.transition = "none";
1378
+ }
1379
+ e.preventDefault();
1380
+ };
1381
+
1382
+ handleMouseMove = (e: MouseEvent) => {
1383
+ if (!this.isMouseDown) return;
1384
+ const dx = e.clientX - this.mouseLastX;
1385
+ const dy = e.clientY - this.mouseLastY;
1386
+ if (
1387
+ Math.hypot(e.clientX - this.mouseStartX, e.clientY - this.mouseStartY) >
1388
+ 5
1389
+ ) {
1390
+ this.hasMouseDragged = true;
1391
+ }
1392
+ this.mouseLastX = e.clientX;
1393
+ this.mouseLastY = e.clientY;
1394
+ this.translateX += dx;
1395
+ this.translateY += dy;
1396
+ this.applyTransform(false);
1397
+ };
1398
+
1399
+ handleMouseUp = () => {
1400
+ if (!this.isMouseDown) return;
1401
+ this.isMouseDown = false;
1402
+ if (this.imgEl) {
1403
+ this.imgEl.classList.remove("is-dragging");
1404
+ this.imgEl.style.cursor = this.scale > 1.01 ? "grab" : "zoom-in";
1405
+ }
1406
+ this.clampToBounds(true);
1407
+ };
1408
+
1409
+ handleDblClick = (e: MouseEvent) => {
1410
+ const target = e.target as HTMLElement;
1411
+ if (target.closest(".hy-gallery-btn, .hy-gallery-close, .hy-gallery-nav")) {
1412
+ return;
1413
+ }
1414
+ if (!this.zoomEnabled) return;
1415
+ e.preventDefault();
1416
+ e.stopPropagation();
1417
+ this.handleDoubleTap(e.clientX, e.clientY);
1418
+ };
1419
+
1420
+ handleResize = () => {
1421
+ if (this.scale > 1.01) {
1422
+ this.clampToBounds(false);
788
1423
  }
789
1424
  };
790
1425