@hoangyell/gallery 1.0.7 → 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.7",
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,18 +566,55 @@ 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;
506
609
  previousBodyTop: string;
507
610
  previousBodyWidth: string;
508
611
  previousHtmlOverflow: string;
612
+ previousHtmlScrollBehavior: string;
509
613
 
510
614
  constructor(el: HTMLElement) {
511
615
  this.gallery = el;
616
+ this.zoomEnabled = el.dataset.zoom !== "false";
617
+ this.zoomControlsEnabled = el.dataset.zoomControls === "true";
512
618
  this.items = Array.from(
513
619
  el.querySelectorAll<HTMLButtonElement>(".hy-gallery-item"),
514
620
  );
@@ -517,15 +623,44 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
517
623
  this.counterEl = null;
518
624
  this.prevBtn = null;
519
625
  this.nextBtn = null;
626
+ this.zoomControls = null;
627
+ this.zoomInBtn = null;
628
+ this.zoomOutBtn = null;
629
+ this.zoomResetBtn = null;
630
+ this.closeBtn = null;
520
631
  this.currentIndex = -1;
632
+
633
+ this.scale = 1;
634
+ this.translateX = 0;
635
+ this.translateY = 0;
636
+
521
637
  this.touchStartX = 0;
522
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
+
523
657
  this.lockedScrollY = -1;
524
658
  this.previousBodyOverflow = "";
525
659
  this.previousBodyPosition = "";
526
660
  this.previousBodyTop = "";
527
661
  this.previousBodyWidth = "";
528
662
  this.previousHtmlOverflow = "";
663
+ this.previousHtmlScrollBehavior = "";
529
664
 
530
665
  this.bind();
531
666
  document.addEventListener("astro:before-swap", this.forceClose);
@@ -542,15 +677,173 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
542
677
  });
543
678
  };
544
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
+
545
829
  forceClose = () => {
546
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
+
547
837
  this.overlay.remove();
548
838
  this.overlay = null;
549
839
  this.imgEl = null;
550
840
  this.counterEl = null;
841
+ this.zoomControls = null;
842
+ this.zoomInBtn = null;
843
+ this.zoomOutBtn = null;
844
+ this.zoomResetBtn = null;
845
+ this.closeBtn = null;
551
846
 
552
- document.removeEventListener("keydown", this.handleKey);
553
- document.removeEventListener("gesturestart", this.preventZoom);
554
847
  const main = document.querySelector("main");
555
848
  if (main) main.removeAttribute("inert");
556
849
  }
@@ -568,6 +861,7 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
568
861
 
569
862
  this.imgEl.src = src;
570
863
  this.imgEl.alt = alt;
864
+ this.resetZoom(false);
571
865
  this.updateCounter();
572
866
  this.updateNavButtons();
573
867
  this.preload(index + 1);
@@ -597,16 +891,70 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
597
891
  this.overlay.setAttribute("aria-modal", "true");
598
892
  this.overlay.setAttribute("aria-label", "Image viewer");
599
893
 
600
- const closeBtn = document.createElement("button");
601
- closeBtn.className = "hy-gallery-close";
602
- closeBtn.setAttribute("aria-label", "Close");
603
- 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>`;
604
- 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);
605
952
 
606
953
  this.prevBtn = document.createElement("button");
607
954
  this.prevBtn.className = "hy-gallery-nav hy-gallery-nav-prev";
955
+ this.prevBtn.setAttribute("type", "button");
608
956
  this.prevBtn.setAttribute("aria-label", "Previous image");
609
- 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>`;
610
958
  this.prevBtn.addEventListener("click", (e) => {
611
959
  e.stopPropagation();
612
960
  this.navigate(-1);
@@ -614,8 +962,9 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
614
962
 
615
963
  this.nextBtn = document.createElement("button");
616
964
  this.nextBtn.className = "hy-gallery-nav hy-gallery-nav-next";
965
+ this.nextBtn.setAttribute("type", "button");
617
966
  this.nextBtn.setAttribute("aria-label", "Next image");
618
- 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>`;
619
968
  this.nextBtn.addEventListener("click", (e) => {
620
969
  e.stopPropagation();
621
970
  this.navigate(1);
@@ -628,12 +977,14 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
628
977
  this.imgEl.className = "hy-gallery-overlay-img";
629
978
  this.imgEl.setAttribute("decoding", "sync");
630
979
 
631
- this.counterEl = document.createElement("div");
632
- this.counterEl.className = "hy-gallery-counter";
633
- this.counterEl.setAttribute("aria-hidden", "true");
634
-
635
980
  content.appendChild(this.imgEl);
636
- 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
+ );
637
988
 
638
989
  if (this.items.length <= 1) {
639
990
  this.prevBtn.style.display = "none";
@@ -642,12 +993,24 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
642
993
  }
643
994
 
644
995
  this.overlay.addEventListener("click", this.handleOverlayClick);
996
+ this.overlay.addEventListener("wheel", this.handleWheel, { passive: false });
645
997
  this.overlay.addEventListener("touchstart", this.handleTouchStart, {
646
- passive: true,
998
+ passive: false,
999
+ });
1000
+ this.overlay.addEventListener("touchmove", this.handleTouchMove, {
1001
+ passive: false,
647
1002
  });
648
1003
  this.overlay.addEventListener("touchend", this.handleTouchEnd, {
649
- passive: true,
1004
+ passive: false,
1005
+ });
1006
+ this.overlay.addEventListener("touchcancel", this.handleTouchEnd, {
1007
+ passive: false,
650
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);
651
1014
  document.addEventListener("keydown", this.handleKey);
652
1015
  };
653
1016
 
@@ -655,20 +1018,31 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
655
1018
  if (!this.overlay) return;
656
1019
 
657
1020
  this.overlay.classList.remove("is-active");
1021
+ this.resetZoom(true);
658
1022
 
659
1023
  setTimeout(() => {
660
- this.overlay?.parentNode?.removeChild(this.overlay);
661
- this.overlay = null;
662
- this.imgEl = null;
663
- 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
+ }
664
1041
 
665
1042
  this.unlockBackgroundScroll();
666
1043
  const main = document.querySelector("main");
667
1044
  if (main) main.removeAttribute("inert");
668
1045
 
669
- document.removeEventListener("keydown", this.handleKey);
670
- document.removeEventListener("gesturestart", this.preventZoom);
671
-
672
1046
  if (this.currentIndex >= 0 && this.currentIndex < this.items.length) {
673
1047
  this.items[this.currentIndex].focus();
674
1048
  }
@@ -684,6 +1058,7 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
684
1058
  this.previousBodyTop = document.body.style.top;
685
1059
  this.previousBodyWidth = document.body.style.width;
686
1060
  this.previousHtmlOverflow = document.documentElement.style.overflow;
1061
+ this.previousHtmlScrollBehavior = document.documentElement.style.scrollBehavior;
687
1062
 
688
1063
  document.documentElement.style.overflow = "hidden";
689
1064
  document.body.style.overflow = "hidden";
@@ -700,7 +1075,12 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
700
1075
  document.body.style.position = this.previousBodyPosition;
701
1076
  document.body.style.top = this.previousBodyTop;
702
1077
  document.body.style.width = this.previousBodyWidth;
703
- window.scrollTo(0, this.lockedScrollY);
1078
+ document.documentElement.style.scrollBehavior = "auto";
1079
+ window.scrollTo({ top: this.lockedScrollY, left: 0, behavior: "auto" });
1080
+ requestAnimationFrame(() => {
1081
+ document.documentElement.style.scrollBehavior =
1082
+ this.previousHtmlScrollBehavior;
1083
+ });
704
1084
  this.lockedScrollY = -1;
705
1085
  };
706
1086
 
@@ -712,6 +1092,8 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
712
1092
  const content = this.overlay?.querySelector(".hy-gallery-overlay-content");
713
1093
  if (!content || !this.imgEl) return;
714
1094
 
1095
+ this.resetZoom(false);
1096
+
715
1097
  const oldImg = this.imgEl;
716
1098
  this.imgEl = document.createElement("img");
717
1099
  this.imgEl.className = "hy-gallery-overlay-img hy-gallery-slide-anim";
@@ -725,8 +1107,17 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
725
1107
 
726
1108
  content.replaceChild(this.imgEl, oldImg);
727
1109
 
1110
+ this.imgEl.addEventListener(
1111
+ "animationend",
1112
+ () => {
1113
+ this.imgEl?.classList.remove("hy-gallery-slide-anim");
1114
+ },
1115
+ { once: true },
1116
+ );
1117
+
728
1118
  this.updateCounter();
729
1119
  this.updateNavButtons();
1120
+ this.updateZoomUI();
730
1121
  this.preload(next + dir);
731
1122
  };
732
1123
 
@@ -751,6 +1142,10 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
751
1142
  };
752
1143
 
753
1144
  handleOverlayClick = (e: MouseEvent) => {
1145
+ if (this.hasMouseDragged) {
1146
+ this.hasMouseDragged = false;
1147
+ return;
1148
+ }
754
1149
  const target = e.target as HTMLElement;
755
1150
  if (
756
1151
  target.classList.contains("hy-gallery-overlay") ||
@@ -761,22 +1156,270 @@ if (resolvedRows <= 1 || galleryItems.length <= 1) {
761
1156
  };
762
1157
 
763
1158
  handleKey = (e: KeyboardEvent) => {
764
- if (e.key === "Escape") this.close();
765
- else if (e.key === "ArrowLeft") this.navigate(-1);
766
- 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
+ }
767
1188
  };
768
1189
 
769
1190
  handleTouchStart = (e: TouchEvent) => {
770
- this.touchStartX = e.changedTouches[0].screenX;
771
- 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
+ }
772
1271
  };
773
1272
 
774
1273
  handleTouchEnd = (e: TouchEvent) => {
775
- const diffX = e.changedTouches[0].screenX - this.touchStartX;
776
- const diffY = e.changedTouches[0].screenY - this.touchStartY;
777
- if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
778
- if (diffX > 0) this.navigate(-1);
779
- 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);
780
1423
  }
781
1424
  };
782
1425