main
js 371 lines 10.4 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2
3 const model = {
4 // State
5 currentImageUrl: null,
6 currentImageName: null,
7 baseImageUrl: null,
8 imageLoaded: false,
9 imageError: false,
10 zoomLevel: 1,
11 refreshInterval: 0,
12 activeIntervalId: null,
13 closePromise: null,
14
15 // Image dimensions
16 naturalWidth: 0,
17 naturalHeight: 0,
18 baseScale: 1,
19
20 // Pan state
21 panX: 0,
22 panY: 0,
23 isDragging: false,
24 dragStartX: 0,
25 dragStartY: 0,
26 dragStartPanX: 0,
27 dragStartPanY: 0,
28 activePointerId: null,
29 windowDragMoveHandler: null,
30 windowDragUpHandler: null,
31
32 /**
33 * Open image viewer modal
34 * @param {string} imageUrl - URL of the image to display
35 * @param {number|object} refreshOrOptions - Either:
36 * - number: refresh interval in ms (legacy compat)
37 * - object: { refreshInterval?: number, name?: string }
38 */
39 async open(imageUrl, refreshOrOptions) {
40 // Parse options (backward compatibility)
41 const options = typeof refreshOrOptions === 'number'
42 ? { refreshInterval: refreshOrOptions, name: null }
43 : refreshOrOptions || {};
44
45 // Reset state
46 this.baseImageUrl = imageUrl;
47 this.refreshInterval = options.refreshInterval || 0;
48 this.currentImageName = options.name || this.extractImageName(imageUrl);
49 this.imageLoaded = false;
50 this.imageError = false;
51 this.zoomLevel = 1;
52
53 // Add timestamp for cache-busting if refreshing
54 this.currentImageUrl = this.refreshInterval > 0
55 ? this.addTimestamp(imageUrl)
56 : imageUrl;
57
58 try {
59 // Open modal and track close promise for cleanup
60 this.closePromise = window.openModal('modals/image-viewer/image-viewer.html');
61
62 // Setup cleanup on modal close
63 if (this.closePromise && typeof this.closePromise.finally === 'function') {
64 this.closePromise.finally(() => {
65 this.stopRefresh();
66 this.resetState();
67 });
68 }
69
70 // Start refresh loop if needed
71 if (this.refreshInterval > 0) {
72 this.setupAutoRefresh();
73 }
74 } catch (error) {
75 console.error("Image viewer error:", error);
76 this.imageError = true;
77 }
78 },
79
80 setupAutoRefresh() {
81 // Clear any existing interval
82 this.stopRefresh();
83
84 this.activeIntervalId = setInterval(() => {
85 if (!this.isModalVisible()) {
86 this.stopRefresh();
87 return;
88 }
89 this.preloadNextImage();
90 }, this.refreshInterval);
91 },
92
93 async preloadNextImage() {
94 const nextSrc = this.addTimestamp(this.baseImageUrl);
95
96 // Create a promise that resolves when the image is loaded
97 const preloadPromise = new Promise((resolve, reject) => {
98 const tempImg = new Image();
99 tempImg.onload = () => resolve(nextSrc);
100 tempImg.onerror = reject;
101 tempImg.src = nextSrc;
102 });
103
104 try {
105 // Wait for preload to complete
106 const loadedSrc = await preloadPromise;
107
108 // Check if modal is still visible before updating
109 if (this.isModalVisible()) {
110 this.currentImageUrl = loadedSrc;
111 this.imageLoaded = false; // Trigger reload animation
112 }
113 } catch (err) {
114 console.error('Failed to preload image:', err);
115 }
116 },
117
118 isModalVisible() {
119 const container = document.querySelector('#image-viewer-wrapper');
120 if (!container) return false;
121
122 // Check if element or any parent is hidden
123 let element = container;
124 while (element) {
125 const styles = window.getComputedStyle(element);
126 if (styles.display === 'none' || styles.visibility === 'hidden') {
127 return false;
128 }
129 element = element.parentElement;
130 }
131 return true;
132 },
133
134 stopRefresh() {
135 if (this.activeIntervalId !== null) {
136 clearInterval(this.activeIntervalId);
137 this.activeIntervalId = null;
138 }
139 },
140
141 resetState() {
142 this.currentImageUrl = null;
143 this.currentImageName = null;
144 this.baseImageUrl = null;
145 this.imageLoaded = false;
146 this.imageError = false;
147 this.zoomLevel = 1;
148 this.refreshInterval = 0;
149 this.naturalWidth = 0;
150 this.naturalHeight = 0;
151 this.baseScale = 1;
152 this.panX = 0;
153 this.panY = 0;
154 this.isDragging = false;
155 this.activePointerId = null;
156 this.removeWindowDragListeners();
157 },
158
159 onImageLoad(event) {
160 const img = event.target;
161 this.naturalWidth = img.naturalWidth;
162 this.naturalHeight = img.naturalHeight;
163 this.imageLoaded = true;
164 this.zoomLevel = 1;
165 this.panX = 0;
166 this.panY = 0;
167 // Reset any previous transform
168 img.classList.remove('zoomed');
169 img.style.width = '';
170 img.style.height = '';
171 img.style.transform = '';
172 },
173
174 // Drag/pan methods
175 startDrag(event) {
176 // Pointer events: mouse (button 0) or touch/pen (button can be -1/undefined)
177 if (typeof event.button === 'number' && event.button !== 0) return;
178 event.preventDefault();
179
180 this.isDragging = true;
181 this.activePointerId = typeof event.pointerId === 'number' ? event.pointerId : null;
182 this.dragStartX = event.clientX;
183 this.dragStartY = event.clientY;
184 this.dragStartPanX = this.panX;
185 this.dragStartPanY = this.panY;
186
187 const canvas = document.querySelector('.image-canvas');
188 if (canvas && this.activePointerId !== null && typeof canvas.setPointerCapture === 'function') {
189 try {
190 canvas.setPointerCapture(this.activePointerId);
191 } catch (e) {
192 // ignore
193 }
194 }
195
196 this.addWindowDragListeners();
197 },
198
199 onDrag(event) {
200 if (!this.isDragging) return;
201 if (this.activePointerId !== null && typeof event.pointerId === 'number' && event.pointerId !== this.activePointerId) {
202 return;
203 }
204 event.preventDefault();
205 const dx = event.clientX - this.dragStartX;
206 const dy = event.clientY - this.dragStartY;
207 this.panX = this.dragStartPanX + dx;
208 this.panY = this.dragStartPanY + dy;
209 this.updateImageTransform();
210 },
211
212 onWheel(event) {
213 // Trackpad scrolling on macOS reports both deltaX and deltaY.
214 // Prevent the modal/page from scrolling and instead pan the image.
215 if (!event) return;
216
217 // Avoid fighting with an active drag.
218 if (this.isDragging) {
219 event.preventDefault();
220 return;
221 }
222
223 // If image isn't ready, ignore.
224 if (!this.currentImageUrl || !this.naturalWidth || !this.naturalHeight) return;
225
226 event.preventDefault();
227
228 // deltaMode: 0=pixels, 1=lines, 2=pages
229 // Convert non-pixel deltas to an approximate pixel value.
230 let dx = event.deltaX || 0;
231 let dy = event.deltaY || 0;
232 if (event.deltaMode === 1) {
233 dx *= 16;
234 dy *= 16;
235 } else if (event.deltaMode === 2) {
236 dx *= 800;
237 dy *= 800;
238 }
239
240 // Pan in the direction of scrolling.
241 this.panX -= dx;
242 this.panY -= dy;
243 this.updateImageTransform();
244 },
245
246 endDrag() {
247 if (!this.isDragging) return;
248 this.isDragging = false;
249 this.activePointerId = null;
250 this.removeWindowDragListeners();
251 },
252
253 addWindowDragListeners() {
254 this.removeWindowDragListeners();
255
256 this.windowDragMoveHandler = (e) => this.onDrag(e);
257 this.windowDragUpHandler = () => this.endDrag();
258
259 window.addEventListener('pointermove', this.windowDragMoveHandler, { passive: false });
260 window.addEventListener('pointerup', this.windowDragUpHandler, { passive: false });
261 window.addEventListener('pointercancel', this.windowDragUpHandler, { passive: false });
262 },
263
264 removeWindowDragListeners() {
265 if (this.windowDragMoveHandler) {
266 window.removeEventListener('pointermove', this.windowDragMoveHandler);
267 this.windowDragMoveHandler = null;
268 }
269 if (this.windowDragUpHandler) {
270 window.removeEventListener('pointerup', this.windowDragUpHandler);
271 window.removeEventListener('pointercancel', this.windowDragUpHandler);
272 this.windowDragUpHandler = null;
273 }
274 },
275
276 clampPan(containerWidth, containerHeight, displayWidth, displayHeight) {
277 const margin = 25;
278
279 const limitX = Math.max(0, (containerWidth + displayWidth) / 2 - margin);
280 const limitY = Math.max(0, (containerHeight + displayHeight) / 2 - margin);
281
282 this.panX = Math.max(-limitX, Math.min(limitX, this.panX));
283 this.panY = Math.max(-limitY, Math.min(limitY, this.panY));
284 },
285
286 getDisplaySize(containerWidth, containerHeight) {
287 // Fit in both dimensions without upscaling
288 const scaleX = containerWidth / this.naturalWidth;
289 const scaleY = containerHeight / this.naturalHeight;
290 const fitScale = Math.min(scaleX, scaleY, 1);
291 const scale = fitScale * this.zoomLevel;
292
293 return {
294 width: this.naturalWidth * scale,
295 height: this.naturalHeight * scale,
296 };
297 },
298
299 // Zoom controls
300 zoomIn() {
301 this.zoomLevel = Math.min(this.zoomLevel * 1.25, 10);
302 this.updateImageTransform();
303 },
304
305 zoomOut() {
306 this.zoomLevel = Math.max(this.zoomLevel / 1.25, 0.1);
307 this.updateImageTransform();
308 },
309
310 resetZoom() {
311 this.zoomLevel = 1;
312 this.panX = 0;
313 this.panY = 0;
314 const img = document.querySelector('.modal-image');
315 if (img) {
316 img.classList.remove('zoomed');
317 img.style.width = '';
318 img.style.height = '';
319 img.style.transform = '';
320 }
321 },
322
323 updateImageTransform() {
324 const img = document.querySelector('.modal-image');
325 const wrapper = document.querySelector('.image-canvas');
326 if (!img || !wrapper || !this.naturalWidth || !this.naturalHeight) return;
327
328 const containerWidth = wrapper.clientWidth;
329 const containerHeight = wrapper.clientHeight;
330
331 const displaySize = this.getDisplaySize(containerWidth, containerHeight);
332 this.clampPan(containerWidth, containerHeight, displaySize.width, displaySize.height);
333
334 img.classList.add('zoomed');
335 img.style.width = `${displaySize.width}px`;
336 img.style.height = `${displaySize.height}px`;
337 img.style.transform = `translate(${this.panX}px, ${this.panY}px)`;
338 },
339
340 // Utility methods
341 addTimestamp(url) {
342 try {
343 const urlObj = new URL(url, window.location.origin);
344 urlObj.searchParams.set("t", Date.now().toString());
345 return urlObj.toString();
346 } catch (e) {
347 // Fallback for invalid URLs
348 const separator = url.includes('?') ? '&' : '?';
349 return `${url}${separator}t=${Date.now()}`;
350 }
351 },
352
353 extractImageName(url) {
354 try {
355 const urlObj = new URL(url, window.location.origin);
356 const pathname = urlObj.pathname;
357 return pathname.split("/").pop() || "Image";
358 } catch (e) {
359 return url.split("/").pop() || "Image";
360 }
361 },
362
363 // Optional: cleanup on store destruction
364 destroy() {
365 this.stopRefresh();
366 this.resetState();
367 },
368 };
369
370 export const store = createStore("imageViewer", model);
371