1
+ ---
2
+ // src/components/Figure.astro
3
+ import { Image } from ' astro:assets' ;
4
+
5
+ type Layout = ' bottom-center' | ' bottom-left' | ' bottom-right' | ' top-hover' | ' left' | ' right' ;
6
+
7
+ interface Props {
8
+ src: string ;
9
+ alt: string ;
10
+ width: number ;
11
+ height: number ;
12
+ class? : string ;
13
+ loading? : ' eager' | ' lazy' ;
14
+ quality? : number | ' low' | ' mid' | ' high' | ' max' ;
15
+ format? : ' avif' | ' png' | ' jpeg' | ' svg' | ' webp' ;
16
+ figureClass? : string ;
17
+ layout? : Layout ;
18
+ zoomable? : boolean ;
19
+ }
20
+
21
+ const {
22
+ figureClass,
23
+ layout = ' bottom-center' ,
24
+ zoomable = false ,
25
+ src,
26
+ alt,
27
+ width,
28
+ height,
29
+ class : className,
30
+ loading,
31
+ quality,
32
+ format,
33
+ } = Astro .props ;
34
+
35
+ const isRowLayout = layout === ' left' || layout === ' right' ;
36
+ ---
37
+
38
+ <figure
39
+ class:list ={ [
40
+ ' group' ,
41
+ figureClass ,
42
+ {
43
+ ' relative' : ! isRowLayout ,
44
+ ' flex items-center gap-4' : isRowLayout ,
45
+ ' flex-row-reverse' : layout === ' left' ,
46
+ ' flex-row' : layout === ' right' ,
47
+ },
48
+ ]}
49
+ data-zoomable ={ zoomable ? ' true' : undefined }
50
+ >
51
+ { isRowLayout ? (
52
+ <div class = " flex-shrink-0 w-1/2" >
53
+ <Image { src } { alt } { width } { height } class = { className } { loading } { quality } { format } />
54
+ </div >
55
+ ) : (
56
+ <Image { src } { alt } { width } { height } class = { className } { loading } { quality } { format } />
57
+ )}
58
+
59
+ { Astro .slots .has (' caption' ) && (
60
+ <figcaption
61
+ class :list = { [
62
+ ' transition-all duration-300' ,
63
+ {
64
+ ' mt-2 text-sm' : layout .startsWith (' bottom' ),
65
+ ' text-center' : layout === ' bottom-center' ,
66
+ ' text-left' : layout === ' bottom-left' ,
67
+ ' text-right' : layout === ' bottom-right' ,
68
+ ' flex-1' : isRowLayout ,
69
+ ' absolute inset-0 flex items-center justify-center p-4 bg-black/60 text-white opacity-0 group-hover:opacity-100' : layout === ' top-hover' ,
70
+ },
71
+ ]}
72
+ >
73
+ <slot name = " caption" />
74
+ </figcaption >
75
+ )}
76
+ </figure >
77
+
78
+ <script >
79
+ function initializeImageViewer() {
80
+ const figures = document.querySelectorAll('figure[data-zoomable="true"]');
81
+
82
+ figures.forEach(figure => {
83
+ const img = figure.querySelector('img');
84
+ if (img instanceof HTMLElement) {
85
+ if (img.dataset.viewerInitialized) return;
86
+ img.dataset.viewerInitialized = 'true';
87
+ img.style.cursor = 'zoom-in';
88
+
89
+ img.addEventListener('click', () => openViewer(img as HTMLImageElement));
90
+ }
91
+ });
92
+
93
+ function openViewer(img: HTMLImageElement) {
94
+ const overlay = document.createElement('div');
95
+ overlay.id = 'image-viewer-overlay';
96
+
97
+ const content = document.createElement('img');
98
+ content.id = 'image-viewer-content';
99
+ content.src = img.src;
100
+
101
+ overlay.appendChild(content);
102
+ document.body.appendChild(overlay);
103
+ document.body.style.overflow = 'hidden'; // Keep this to prevent background scroll
104
+
105
+ let scale = 1;
106
+ let isDragging = false;
107
+ let startX = 0, startY = 0;
108
+ let translateX = 0, translateY = 0;
109
+
110
+ const updateTransform = () => {
111
+ content.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
112
+ };
113
+
114
+ const wheelHandler = (e: WheelEvent) => {
115
+ e.preventDefault();
116
+ const scaleAmount = e.deltaY > 0 ? -0.1 : 0.1;
117
+ scale = Math.max(0.5, Math.min(scale + scaleAmount, 5));
118
+ updateTransform();
119
+ };
120
+
121
+ const mouseDownHandler = (e: MouseEvent) => {
122
+ e.preventDefault();
123
+ isDragging = true;
124
+ startX = e.clientX - translateX;
125
+ startY = e.clientY - translateY;
126
+ content.style.cursor = 'grabbing';
127
+ };
128
+
129
+ const mouseMoveHandler = (e: MouseEvent) => {
130
+ if (!isDragging) return;
131
+ translateX = e.clientX - startX;
132
+ translateY = e.clientY - startY;
133
+ updateTransform();
134
+ };
135
+
136
+ const mouseUpHandler = () => {
137
+ isDragging = false;
138
+ content.style.cursor = 'grab';
139
+ };
140
+
141
+ const closeModal = () => {
142
+ document.body.style.overflow = 'auto';
143
+ overlay.remove();
144
+ window.removeEventListener('mousemove', mouseMoveHandler);
145
+ window.removeEventListener('mouseup', mouseUpHandler);
146
+ };
147
+
148
+ overlay.addEventListener('click', closeModal);
149
+ content.addEventListener('click', (e) => e.stopPropagation());
150
+ overlay.addEventListener('wheel', wheelHandler);
151
+ content.addEventListener('mousedown', mouseDownHandler);
152
+ window.addEventListener('mousemove', mouseMoveHandler);
153
+ window.addEventListener('mouseup', mouseUpHandler);
154
+ }
155
+ }
156
+
157
+ initializeImageViewer();
158
+ document.addEventListener('astro:after-swap', initializeImageViewer);
159
+ </script >
160
+
161
+ <style is:global >
162
+ #image-viewer-overlay {
163
+ position: fixed;
164
+ inset: 0;
165
+ z-index: 9999;
166
+ display: flex;
167
+ align-items: center;
168
+ justify-content: center;
169
+ padding: 2rem;
170
+ background-color: rgba(255, 255, 255, 0.85);
171
+ backdrop-filter: blur(8px);
172
+ /* Removed transition for opacity */
173
+ }
174
+
175
+ html.dark #image-viewer-overlay {
176
+ background-color: rgba(10, 10, 10, 0.85);
177
+ }
178
+
179
+ #image-viewer-content {
180
+ max-width: 100%;
181
+ max-height: 100%;
182
+ object-fit: contain;
183
+ border-radius: 8px;
184
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
185
+ transform-origin: center center;
186
+ transition: transform 0.15s linear;
187
+ cursor: grab;
188
+ }
189
+ </style >
0 commit comments