@marianmeres/stuic 2.43.0 → 2.45.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.
@@ -75,6 +75,8 @@
75
75
  ) => void;
76
76
  /** optional "do not display file name" switch flag */
77
77
  noName?: boolean;
78
+ /** When true (default), panning is clamped to keep image within bounds */
79
+ clampPan?: boolean;
78
80
  }
79
81
 
80
82
  export function getAssetIcon(ext?: string) {
@@ -181,6 +183,7 @@
181
183
  classControls = "",
182
184
  onDelete,
183
185
  noName,
186
+ clampPan = false,
184
187
  }: Props = $props();
185
188
 
186
189
  let assets: AssetPreviewNormalized[] = $derived(
@@ -192,8 +195,18 @@
192
195
 
193
196
  // Zoom state
194
197
  const ZOOM_LEVELS = [1, 1.5, 2, 3, 4] as const;
198
+ const MIN_ZOOM = ZOOM_LEVELS[0];
199
+ const MAX_ZOOM = ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
195
200
  let zoomLevelIdx = $state(0);
196
- let zoomLevel = $derived(ZOOM_LEVELS[zoomLevelIdx]);
201
+
202
+ // Pinch zoom state
203
+ let isPinching = $state(false);
204
+ let initialPinchDistance = 0;
205
+ let initialPinchZoom = 1;
206
+ let continuousZoom = $state(1);
207
+
208
+ // Use continuous zoom during pinch, discrete levels otherwise
209
+ let zoomLevel = $derived(isPinching ? continuousZoom : ZOOM_LEVELS[zoomLevelIdx]);
197
210
 
198
211
  // Pan state
199
212
  let isPanning = $state(false);
@@ -229,11 +242,22 @@
229
242
  }
230
243
  });
231
244
 
245
+ // Wheel zoom handler
246
+ function handleWheel(e: WheelEvent) {
247
+ e.preventDefault();
248
+ if (e.deltaY > 0) {
249
+ zoomOut();
250
+ } else {
251
+ zoomIn();
252
+ }
253
+ }
254
+
232
255
  // Svelte action for pan event listeners - guaranteed to run when element is created
233
256
  function pannable(node: HTMLImageElement) {
234
257
  imgEl = node;
235
258
  node.addEventListener("mousedown", panStart);
236
259
  node.addEventListener("touchstart", panStart, { passive: false });
260
+ node.addEventListener("wheel", handleWheel, { passive: false });
237
261
 
238
262
  document.addEventListener("mousemove", panMove);
239
263
  document.addEventListener("mouseup", panEnd);
@@ -246,6 +270,7 @@
246
270
  imgEl = null;
247
271
  node.removeEventListener("mousedown", panStart);
248
272
  node.removeEventListener("touchstart", panStart);
273
+ node.removeEventListener("wheel", handleWheel);
249
274
  document.removeEventListener("mousemove", panMove);
250
275
  document.removeEventListener("mouseup", panEnd);
251
276
  document.removeEventListener("touchmove", panMove);
@@ -256,7 +281,7 @@
256
281
  }
257
282
 
258
283
  // Clamp pan values to keep image within bounds
259
- function clampPan(newPanX: number, newPanY: number): { x: number; y: number } {
284
+ function getClampedPan(newPanX: number, newPanY: number): { x: number; y: number } {
260
285
  if (!imgEl || !containerEl) return { x: newPanX, y: newPanY };
261
286
 
262
287
  const imgRect = imgEl.getBoundingClientRect();
@@ -297,12 +322,45 @@
297
322
 
298
323
  function resetZoom() {
299
324
  zoomLevelIdx = 0;
325
+ continuousZoom = 1;
300
326
  panX = 0;
301
327
  panY = 0;
328
+ isPinching = false;
329
+ }
330
+
331
+ // Pinch zoom helpers
332
+ function getDistance(touch1: Touch, touch2: Touch): number {
333
+ const dx = touch1.clientX - touch2.clientX;
334
+ const dy = touch1.clientY - touch2.clientY;
335
+ return Math.hypot(dx, dy);
336
+ }
337
+
338
+ function findNearestZoomLevelIdx(zoom: number): number {
339
+ let nearestIdx = 0;
340
+ let minDiff = Math.abs(ZOOM_LEVELS[0] - zoom);
341
+ for (let i = 1; i < ZOOM_LEVELS.length; i++) {
342
+ const diff = Math.abs(ZOOM_LEVELS[i] - zoom);
343
+ if (diff < minDiff) {
344
+ minDiff = diff;
345
+ nearestIdx = i;
346
+ }
347
+ }
348
+ return nearestIdx;
302
349
  }
303
350
 
304
351
  // Pan/drag handlers
305
352
  function panStart(e: MouseEvent | TouchEvent) {
353
+ // Detect two-finger pinch gesture
354
+ if ("touches" in e && e.touches.length === 2) {
355
+ e.preventDefault();
356
+ isPinching = true;
357
+ isPanning = false;
358
+ initialPinchDistance = getDistance(e.touches[0], e.touches[1]);
359
+ initialPinchZoom = continuousZoom;
360
+ return;
361
+ }
362
+
363
+ // Single-finger pan (only when zoomed in)
306
364
  if (zoomLevel <= 1) return;
307
365
  e.preventDefault();
308
366
  isPanning = true;
@@ -317,6 +375,16 @@
317
375
  }
318
376
 
319
377
  function panMove(e: MouseEvent | TouchEvent) {
378
+ // Handle pinch zoom
379
+ if ("touches" in e && e.touches.length === 2 && isPinching) {
380
+ e.preventDefault();
381
+ const currentDistance = getDistance(e.touches[0], e.touches[1]);
382
+ const scale = currentDistance / initialPinchDistance;
383
+ continuousZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, initialPinchZoom * scale));
384
+ return;
385
+ }
386
+
387
+ // Handle single-finger pan
320
388
  if (!isPanning) return;
321
389
  e.preventDefault();
322
390
 
@@ -326,12 +394,29 @@
326
394
  const newPanX = startPanX + (clientX - startMouseX);
327
395
  const newPanY = startPanY + (clientY - startMouseY);
328
396
 
329
- const clamped = clampPan(newPanX, newPanY);
330
- panX = clamped.x;
331
- panY = clamped.y;
397
+ if (clampPan) {
398
+ const clamped = getClampedPan(newPanX, newPanY);
399
+ panX = clamped.x;
400
+ panY = clamped.y;
401
+ } else {
402
+ panX = newPanX;
403
+ panY = newPanY;
404
+ }
332
405
  }
333
406
 
334
407
  function panEnd() {
408
+ // Handle pinch end - snap to nearest discrete level
409
+ if (isPinching) {
410
+ isPinching = false;
411
+ zoomLevelIdx = findNearestZoomLevelIdx(continuousZoom);
412
+ continuousZoom = ZOOM_LEVELS[zoomLevelIdx];
413
+ // Reset pan when zoomed out to 1x
414
+ if (zoomLevelIdx === 0) {
415
+ panX = 0;
416
+ panY = 0;
417
+ }
418
+ return;
419
+ }
335
420
  isPanning = false;
336
421
  }
337
422
 
@@ -394,7 +479,7 @@
394
479
  <Modal
395
480
  bind:this={modal}
396
481
  onEscape={modal?.close}
397
- classBackdrop="p-4 md:p-4 {modalClassBackdrop}"
482
+ classBackdrop="p-2 md:p-2 {modalClassBackdrop}"
398
483
  classInner="max-w-full h-full {modalClassInner}"
399
484
  class="max-h-full md:max-h-full rounded-lg {modalClass}"
400
485
  classMain="flex items-center justify-center relative stuic-assets-preview stuic-assets-preview-open {modalClassMain}"
@@ -24,6 +24,8 @@ export interface Props {
24
24
  }) => void;
25
25
  /** optional "do not display file name" switch flag */
26
26
  noName?: boolean;
27
+ /** When true (default), panning is clamped to keep image within bounds */
28
+ clampPan?: boolean;
27
29
  }
28
30
  export declare function getAssetIcon(ext?: string): CallableFunction;
29
31
  declare const AssetsPreview: import("svelte").Component<Props, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.43.0",
3
+ "version": "2.45.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",