@marianmeres/stuic 2.39.1 → 2.41.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.
@@ -0,0 +1,547 @@
1
+ <script lang="ts" module>
2
+ import { iconBsFileEarmark } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmark.js";
3
+ import { iconBsFileEarmarkBinary } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkBinary.js";
4
+ import { iconBsFileEarmarkCode } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkCode.js";
5
+ import { iconBsFileEarmarkImage } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkImage.js";
6
+ import { iconBsFileEarmarkMusic } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkMusic.js";
7
+ import { iconBsFileEarmarkPdf } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkPdf.js";
8
+ import { iconBsFileEarmarkRichtext } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkRichtext.js";
9
+ import { iconBsFileEarmarkSlides } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkSlides.js";
10
+ import { iconBsFileEarmarkSpreadsheet } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkSpreadsheet.js";
11
+ import { iconBsFileEarmarkText } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkText.js";
12
+ import { iconBsFileEarmarkWord } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkWord.js";
13
+ import { iconBsFileEarmarkZip } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkZip.js";
14
+ import { iconFeatherArrowLeft as iconPrevious } from "@marianmeres/icons-fns/feather/iconFeatherArrowLeft.js";
15
+ import { iconFeatherArrowRight as iconNext } from "@marianmeres/icons-fns/feather/iconFeatherArrowRight.js";
16
+ import { iconFeatherDownload as iconDownload } from "@marianmeres/icons-fns/feather/iconFeatherDownload.js";
17
+ import { iconFeatherPlus as iconAdd } from "@marianmeres/icons-fns/feather/iconFeatherPlus.js";
18
+ import { iconFeatherTrash2 as iconDelete } from "@marianmeres/icons-fns/feather/iconFeatherTrash2.js";
19
+ import { iconFeatherZoomIn } from "@marianmeres/icons-fns/feather/iconFeatherZoomIn.js";
20
+ import { iconFeatherZoomOut } from "@marianmeres/icons-fns/feather/iconFeatherZoomOut.js";
21
+ import { getFileTypeLabel } from "../../utils/get-file-type-label.js";
22
+ import { Modal } from "../Modal/index.js";
23
+ import { createClog } from "@marianmeres/clog";
24
+ import { isImage } from "../../utils/is-image.js";
25
+ import { isPlainObject } from "../../utils/is-plain-object.js";
26
+ import { replaceMap } from "../../utils/replace-map.js";
27
+ import type { TranslateFn } from "../../types.js";
28
+ import { twMerge } from "../../utils/tw-merge.js";
29
+ import { forceDownload } from "../../utils/force-download.js";
30
+ import { tooltip } from "../../actions/index.js";
31
+ import { X } from "../X/index.js";
32
+ import { preloadImgs } from "../../utils/preload-img.js";
33
+ import { fade } from "svelte/transition";
34
+
35
+ export type AssetPreviewUrlObj = {
36
+ // o
37
+ thumb: string | URL;
38
+ // used in modal preview
39
+ full: string | URL;
40
+ // (potentially extra high res) used for download
41
+ original: string | URL;
42
+ };
43
+
44
+ export interface AssetPreview {
45
+ url: AssetPreviewUrlObj;
46
+ name?: string;
47
+ type?: string;
48
+ }
49
+
50
+ interface AssetPreviewNormalized extends AssetPreview {
51
+ name: string;
52
+ type: string;
53
+ ext: string;
54
+ isImage: boolean;
55
+ }
56
+
57
+ export interface Props {
58
+ assets: string[] | AssetPreview[];
59
+ classControls?: string;
60
+ /** Optional translate function */
61
+ t?: TranslateFn;
62
+ /** Optional delete handler - receives the current asset and its index */
63
+ onDelete?: (
64
+ asset: AssetPreview,
65
+ index: number,
66
+ controls: {
67
+ close: () => void;
68
+ }
69
+ ) => void;
70
+ }
71
+
72
+ export function getAssetIcon(ext?: string) {
73
+ const map: Record<string, CallableFunction> = {
74
+ archive: iconBsFileEarmarkZip,
75
+ audio: iconBsFileEarmarkMusic,
76
+ binary: iconBsFileEarmarkBinary,
77
+ code: iconBsFileEarmarkCode,
78
+ doc: iconBsFileEarmarkWord,
79
+ image: iconBsFileEarmarkImage,
80
+ pdf: iconBsFileEarmarkPdf,
81
+ presentation: iconBsFileEarmarkSlides,
82
+ richtext: iconBsFileEarmarkRichtext,
83
+ spreadsheet: iconBsFileEarmarkSpreadsheet,
84
+ text: iconBsFileEarmarkText,
85
+ unknown: iconBsFileEarmark,
86
+ };
87
+ return map[getFileTypeLabel(ext ?? "unknown")] ?? iconBsFileEarmark;
88
+ }
89
+
90
+ // i18n ready
91
+ function t_default(
92
+ k: string,
93
+ values: false | null | undefined | Record<string, string | number> = null,
94
+ fallback: string | boolean = "",
95
+ i18nSpanWrap: boolean = true
96
+ ) {
97
+ const m: Record<string, string> = {
98
+ unable_to_preview: "Unable to preview",
99
+ download: "Download",
100
+ close: "Close",
101
+ zoom_in: "Zoom in",
102
+ zoom_out: "Zoom out",
103
+ delete: "Delete",
104
+ };
105
+ let out = m[k] ?? fallback ?? k;
106
+
107
+ return isPlainObject(values) ? replaceMap(out, values as any) : out;
108
+ }
109
+
110
+ // naive best-effort
111
+ function ext(name: string): string {
112
+ const _ext = name.split(".").at(-1) ?? "";
113
+ return _ext ? `.${_ext}` : "";
114
+ }
115
+
116
+ function normalizeInput(input: string | AssetPreview): AssetPreviewNormalized | null {
117
+ const asset: AssetPreviewNormalized = {
118
+ name: "",
119
+ type: "",
120
+ url: { full: "", thumb: "", original: "" },
121
+ isImage: false,
122
+ ext: "",
123
+ };
124
+ if (typeof input === "string") {
125
+ asset.url.full = input;
126
+ } else {
127
+ // Handle AssetPreview object
128
+ asset.url = { ...input.url };
129
+ asset.name = input.name ?? "";
130
+ asset.type = input.type ?? "";
131
+ }
132
+
133
+ if (!asset.url.full) {
134
+ return null;
135
+ }
136
+
137
+ asset.url.full = new URL(
138
+ asset.url.full,
139
+ globalThis.location?.href ?? "http://placeholder"
140
+ );
141
+
142
+ // Use "full" also as "thumb" and "original", if not provided
143
+ asset.url.thumb ||= asset.url.full;
144
+ asset.url.original ||= asset.url.full;
145
+
146
+ // best-effort..
147
+ if (!asset.name) {
148
+ asset.name = asset.url.full.pathname.split("/").at(-1) ?? "";
149
+ }
150
+
151
+ // best-effort..
152
+ if (!asset.type) {
153
+ asset.type = getFileTypeLabel(ext(asset.name) ?? "unknown");
154
+ }
155
+
156
+ asset.ext = ext(asset.name);
157
+ asset.isImage = isImage(asset.ext);
158
+
159
+ return asset;
160
+ }
161
+ </script>
162
+
163
+ <script lang="ts">
164
+ const clog = createClog("AssetsPreview", { color: "auto" });
165
+
166
+ let { assets: _assets, t = t_default, classControls = "", onDelete }: Props = $props();
167
+
168
+ let assets: AssetPreviewNormalized[] = $derived(
169
+ (_assets ?? []).map(normalizeInput).filter(Boolean) as AssetPreviewNormalized[]
170
+ );
171
+ let previewIdx = $state<number>(0);
172
+ let modal: Modal | undefined = $state();
173
+ let dotTooltip: string | undefined = $state();
174
+
175
+ // Zoom state
176
+ const ZOOM_LEVELS = [1, 1.5, 2, 3, 4] as const;
177
+ let zoomLevelIdx = $state(0);
178
+ let zoomLevel = $derived(ZOOM_LEVELS[zoomLevelIdx]);
179
+
180
+ // Pan state
181
+ let isPanning = $state(false);
182
+ let panX = $state(0);
183
+ let panY = $state(0);
184
+ let startPanX = 0;
185
+ let startPanY = 0;
186
+ let startMouseX = 0;
187
+ let startMouseY = 0;
188
+
189
+ // Image and container dimensions for pan clamping
190
+ let imgEl: HTMLImageElement | null = null;
191
+ let containerEl: HTMLDivElement | null = $state(null);
192
+
193
+ const TOP_BUTTON_CLS = "rounded bg-white hover:bg-neutral-200 p-1";
194
+
195
+ $effect(() => {
196
+ const visible = modal?.visibility().visible;
197
+ if (visible) {
198
+ // Reset preview index on modal open
199
+ previewIdx = 0;
200
+
201
+ // perhaps we should have some upper limit here...
202
+ const toPreload = (assets ?? [])
203
+ .map((asset) => (asset.isImage ? String(asset.url.full) : ""))
204
+ .filter(Boolean);
205
+
206
+ clog.debug("going to (maybe) preload", toPreload);
207
+ preloadImgs(toPreload);
208
+ } else {
209
+ // Reset zoom when modal closes
210
+ resetZoom();
211
+ }
212
+ });
213
+
214
+ // Svelte action for pan event listeners - guaranteed to run when element is created
215
+ function pannable(node: HTMLImageElement) {
216
+ imgEl = node;
217
+ node.addEventListener("mousedown", panStart);
218
+ node.addEventListener("touchstart", panStart, { passive: false });
219
+
220
+ document.addEventListener("mousemove", panMove);
221
+ document.addEventListener("mouseup", panEnd);
222
+ document.addEventListener("touchmove", panMove, { passive: false });
223
+ document.addEventListener("touchend", panEnd);
224
+ document.addEventListener("touchcancel", panEnd);
225
+
226
+ return {
227
+ destroy() {
228
+ imgEl = null;
229
+ node.removeEventListener("mousedown", panStart);
230
+ node.removeEventListener("touchstart", panStart);
231
+ document.removeEventListener("mousemove", panMove);
232
+ document.removeEventListener("mouseup", panEnd);
233
+ document.removeEventListener("touchmove", panMove);
234
+ document.removeEventListener("touchend", panEnd);
235
+ document.removeEventListener("touchcancel", panEnd);
236
+ },
237
+ };
238
+ }
239
+
240
+ // Clamp pan values to keep image within bounds
241
+ function clampPan(newPanX: number, newPanY: number): { x: number; y: number } {
242
+ if (!imgEl || !containerEl) return { x: newPanX, y: newPanY };
243
+
244
+ const imgRect = imgEl.getBoundingClientRect();
245
+ const containerRect = containerEl.getBoundingClientRect();
246
+
247
+ // Calculate the scaled image dimensions
248
+ const scaledWidth = imgRect.width; // already includes transform scale
249
+ const scaledHeight = imgRect.height;
250
+
251
+ // Calculate max pan distance (how much the scaled image exceeds the container)
252
+ // Divide by zoomLevel because translate values are applied before scale in our transform
253
+ const maxPanX = Math.max(0, (scaledWidth - containerRect.width) / 2 / zoomLevel);
254
+ const maxPanY = Math.max(0, (scaledHeight - containerRect.height) / 2 / zoomLevel);
255
+
256
+ return {
257
+ x: Math.max(-maxPanX, Math.min(maxPanX, newPanX)),
258
+ y: Math.max(-maxPanY, Math.min(maxPanY, newPanY)),
259
+ };
260
+ }
261
+
262
+ // Zoom functions
263
+ function zoomIn() {
264
+ if (zoomLevelIdx < ZOOM_LEVELS.length - 1) {
265
+ zoomLevelIdx++;
266
+ }
267
+ }
268
+
269
+ function zoomOut() {
270
+ if (zoomLevelIdx > 0) {
271
+ zoomLevelIdx--;
272
+ // Reset pan when zooming out to 1x
273
+ if (zoomLevelIdx === 0) {
274
+ panX = 0;
275
+ panY = 0;
276
+ }
277
+ }
278
+ }
279
+
280
+ function resetZoom() {
281
+ zoomLevelIdx = 0;
282
+ panX = 0;
283
+ panY = 0;
284
+ }
285
+
286
+ // Pan/drag handlers
287
+ function panStart(e: MouseEvent | TouchEvent) {
288
+ if (zoomLevel <= 1) return;
289
+ e.preventDefault();
290
+ isPanning = true;
291
+
292
+ const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
293
+ const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
294
+
295
+ startMouseX = clientX;
296
+ startMouseY = clientY;
297
+ startPanX = panX;
298
+ startPanY = panY;
299
+ }
300
+
301
+ function panMove(e: MouseEvent | TouchEvent) {
302
+ if (!isPanning) return;
303
+ e.preventDefault();
304
+
305
+ const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
306
+ const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
307
+
308
+ const newPanX = startPanX + (clientX - startMouseX);
309
+ const newPanY = startPanY + (clientY - startMouseY);
310
+
311
+ const clamped = clampPan(newPanX, newPanY);
312
+ panX = clamped.x;
313
+ panY = clamped.y;
314
+ }
315
+
316
+ function panEnd() {
317
+ isPanning = false;
318
+ }
319
+
320
+ export function open(index?: number) {
321
+ if (typeof index === "number") {
322
+ previewIdx = index;
323
+ }
324
+ modal?.open();
325
+ }
326
+
327
+ export function close() {
328
+ modal?.close();
329
+ }
330
+
331
+ export function visibility() {
332
+ return (
333
+ modal?.visibility() ?? {
334
+ get visible() {
335
+ return false;
336
+ },
337
+ }
338
+ );
339
+ }
340
+
341
+ export function setOpener(opener?: null | HTMLElement) {
342
+ modal?.setOpener(opener);
343
+ }
344
+
345
+ function preview_previous() {
346
+ previewIdx = (previewIdx - 1 + assets.length) % assets.length;
347
+ resetZoom();
348
+ }
349
+
350
+ function preview_next() {
351
+ previewIdx = (previewIdx + 1) % assets.length;
352
+ resetZoom();
353
+ }
354
+
355
+ function preview(idx: number) {
356
+ previewIdx = idx % assets.length;
357
+ }
358
+
359
+ // $inspect(assets).with(clog);
360
+ </script>
361
+
362
+ <!-- this must be on window as we're catching any typing anywhere -->
363
+ <svelte:window
364
+ onkeydown={(e) => {
365
+ if (modal?.visibility().visible) {
366
+ if (["ArrowRight"].includes(e.key)) {
367
+ preview_next();
368
+ } else if (["ArrowLeft"].includes(e.key)) {
369
+ preview_previous();
370
+ }
371
+ }
372
+ }}
373
+ />
374
+
375
+ {#if assets.length}
376
+ <Modal
377
+ bind:this={modal}
378
+ onEscape={modal?.close}
379
+ classBackdrop="p-4 md:p-4"
380
+ classInner="max-w-full h-full"
381
+ class="max-h-full md:max-h-full rounded-lg"
382
+ classMain="flex items-center justify-center relative stuic-assets-preview stuic-assets-preview-open"
383
+ >
384
+ {@const previewAsset = assets?.[previewIdx]}
385
+ {#if previewAsset}
386
+ <!-- <pre>{JSON.stringify(previewAsset)}</pre> -->
387
+ {#if previewAsset.isImage}
388
+ <div
389
+ bind:this={containerEl}
390
+ class="w-full h-full overflow-hidden flex items-center justify-center"
391
+ >
392
+ <img
393
+ use:pannable
394
+ src={String(previewAsset.url.full)}
395
+ class="max-w-full max-h-full object-scale-down select-none"
396
+ class:cursor-grab={zoomLevel > 1 && !isPanning}
397
+ class:cursor-grabbing={isPanning}
398
+ alt={previewAsset?.name}
399
+ style:transform="scale({zoomLevel}) translate({panX / zoomLevel}px, {panY /
400
+ zoomLevel}px)"
401
+ style:transform-origin="center center"
402
+ draggable="false"
403
+ />
404
+ </div>
405
+ {:else}
406
+ <div>
407
+ <div>
408
+ {@html getAssetIcon(previewAsset.ext)({
409
+ size: 32,
410
+ class: "mx-auto",
411
+ })}
412
+ </div>
413
+ <div class="opacity-50 mt-4">{t("unable_to_preview")}</div>
414
+ </div>
415
+ {/if}
416
+
417
+ {#if assets?.length > 1}
418
+ <div
419
+ class="absolute inset-0 flex items-center justify-between pointer-events-none"
420
+ >
421
+ <button
422
+ class={twMerge("p-4 pointer-events-auto", classControls)}
423
+ onclick={preview_previous}
424
+ type="button"
425
+ >
426
+ <span class="bg-white rounded-full p-3 block">
427
+ {@html iconPrevious()}
428
+ </span>
429
+ </button>
430
+
431
+ <button
432
+ class={twMerge("p-4 pointer-events-auto", classControls)}
433
+ onclick={preview_next}
434
+ type="button"
435
+ >
436
+ <span class="bg-white rounded-full p-3 block">
437
+ {@html iconNext()}
438
+ </span>
439
+ </button>
440
+ </div>
441
+ {/if}
442
+
443
+ <div class="absolute top-4 right-4 flex items-center space-x-3">
444
+ {#if previewAsset.isImage}
445
+ <button
446
+ class={twMerge(TOP_BUTTON_CLS, classControls)}
447
+ type="button"
448
+ onclick={zoomOut}
449
+ disabled={zoomLevelIdx === 0}
450
+ aria-label={t("zoom_out")}
451
+ use:tooltip
452
+ >
453
+ {@html iconFeatherZoomOut({ class: "size-6" })}
454
+ </button>
455
+
456
+ <button
457
+ class={twMerge(TOP_BUTTON_CLS, classControls)}
458
+ type="button"
459
+ onclick={zoomIn}
460
+ disabled={zoomLevelIdx === ZOOM_LEVELS.length - 1}
461
+ aria-label={t("zoom_in")}
462
+ use:tooltip
463
+ >
464
+ {@html iconFeatherZoomIn({ class: "size-6" })}
465
+ </button>
466
+ {/if}
467
+
468
+ {#if typeof onDelete === "function"}
469
+ <button
470
+ class={twMerge(TOP_BUTTON_CLS, classControls)}
471
+ type="button"
472
+ onclick={() => onDelete(previewAsset, previewIdx, { close })}
473
+ aria-label={t("delete")}
474
+ use:tooltip
475
+ >
476
+ {@html iconDelete({ class: "size-6" })}
477
+ </button>
478
+ {/if}
479
+
480
+ <button
481
+ class={twMerge(TOP_BUTTON_CLS, classControls)}
482
+ type="button"
483
+ onclick={(e) => {
484
+ e.preventDefault();
485
+ forceDownload(String(previewAsset.url.original), previewAsset?.name || "");
486
+ }}
487
+ aria-label={t("download")}
488
+ use:tooltip
489
+ >
490
+ {@html iconDownload({ class: "size-6" })}
491
+ </button>
492
+
493
+ <button
494
+ class={twMerge(TOP_BUTTON_CLS, classControls)}
495
+ onclick={modal?.close}
496
+ aria-label={t("close")}
497
+ type="button"
498
+ use:tooltip
499
+ >
500
+ <X />
501
+ </button>
502
+ </div>
503
+
504
+ {#if previewAsset?.name}
505
+ <span class="absolute top-4 left-4 bg-white px-1 rounded">
506
+ {previewAsset?.name}
507
+ </span>
508
+ {/if}
509
+
510
+ {#if assets.length > 1}
511
+ {#if dotTooltip}
512
+ <div
513
+ class="absolute bottom-10 left-0 right-0 text-center"
514
+ transition:fade={{ duration: 100 }}
515
+ >
516
+ <span class="bg-white p-1 rounded opacity/75">
517
+ {dotTooltip}
518
+ </span>
519
+ </div>
520
+ {/if}
521
+ <div class="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-3">
522
+ {#each assets as _, i}
523
+ <!-- svelte-ignore a11y_mouse_events_have_key_events -->
524
+ <button
525
+ type="button"
526
+ class={twMerge(
527
+ "size-3 rounded-full transition-colors border border-black/50",
528
+ i === previewIdx ? "bg-white" : "bg-white/50 hover:bg-neutral-200"
529
+ )}
530
+ onclick={() => {
531
+ previewIdx = i;
532
+ resetZoom();
533
+ }}
534
+ aria-label={assets[i]?.name}
535
+ onmouseover={() => {
536
+ dotTooltip = assets[i]?.name;
537
+ }}
538
+ onmouseout={() => {
539
+ dotTooltip = undefined;
540
+ }}
541
+ ></button>
542
+ {/each}
543
+ </div>
544
+ {/if}
545
+ {/if}
546
+ </Modal>
547
+ {/if}
@@ -0,0 +1,32 @@
1
+ import type { TranslateFn } from "../../types.js";
2
+ export type AssetPreviewUrlObj = {
3
+ thumb: string | URL;
4
+ full: string | URL;
5
+ original: string | URL;
6
+ };
7
+ export interface AssetPreview {
8
+ url: AssetPreviewUrlObj;
9
+ name?: string;
10
+ type?: string;
11
+ }
12
+ export interface Props {
13
+ assets: string[] | AssetPreview[];
14
+ classControls?: string;
15
+ /** Optional translate function */
16
+ t?: TranslateFn;
17
+ /** Optional delete handler - receives the current asset and its index */
18
+ onDelete?: (asset: AssetPreview, index: number, controls: {
19
+ close: () => void;
20
+ }) => void;
21
+ }
22
+ export declare function getAssetIcon(ext?: string): CallableFunction;
23
+ declare const AssetsPreview: import("svelte").Component<Props, {
24
+ open: (index?: number) => void;
25
+ close: () => void;
26
+ visibility: () => {
27
+ readonly visible: boolean;
28
+ };
29
+ setOpener: (opener?: null | HTMLElement) => void;
30
+ }, "">;
31
+ type AssetsPreview = ReturnType<typeof AssetsPreview>;
32
+ export default AssetsPreview;
@@ -0,0 +1,166 @@
1
+ # AssetsPreview
2
+
3
+ A modal-based asset preview component for displaying images and files. Supports image zoom, pan/drag navigation, keyboard shortcuts, and multi-asset navigation with thumbnails.
4
+
5
+ ## Props
6
+
7
+ | Prop | Type | Default | Description |
8
+ |------|------|---------|-------------|
9
+ | `assets` | `string[] \| AssetPreview[]` | - | Array of assets to preview |
10
+ | `classControls` | `string` | - | CSS for control buttons |
11
+ | `t` | `TranslateFn` | built-in | Translation function for i18n |
12
+ | `onDelete` | `(asset, index) => void` | - | Optional delete handler |
13
+
14
+ ## Types
15
+
16
+ ```typescript
17
+ type AssetPreviewUrlObj = {
18
+ thumb: string | URL; // Thumbnail URL
19
+ full: string | URL; // Full resolution for preview
20
+ original: string | URL; // Original for download
21
+ };
22
+
23
+ interface AssetPreview {
24
+ url: AssetPreviewUrlObj;
25
+ name?: string; // Display name
26
+ type?: string; // MIME type or file type
27
+ }
28
+ ```
29
+
30
+ ## Methods
31
+
32
+ | Method | Description |
33
+ |--------|-------------|
34
+ | `open(index?)` | Open preview, optionally at specific asset index |
35
+ | `close()` | Close preview |
36
+ | `visibility()` | Returns object with `visible` getter |
37
+ | `setOpener(el)` | Set element to refocus when closed |
38
+
39
+ ## Features
40
+
41
+ - **Image zoom**: 5 zoom levels (1x, 1.5x, 2x, 3x, 4x)
42
+ - **Pan/drag**: Click and drag to pan zoomed images
43
+ - **Keyboard navigation**: Arrow keys for prev/next
44
+ - **Touch support**: Touch gestures for pan
45
+ - **Auto-preload**: Preloads full-resolution images when modal opens
46
+ - **File type icons**: Displays appropriate icons for non-image files
47
+
48
+ ## Translation Keys
49
+
50
+ | Key | Default |
51
+ |-----|---------|
52
+ | `unable_to_preview` | "Unable to preview" |
53
+ | `download` | "Download" |
54
+ | `close` | "Close" |
55
+ | `zoom_in` | "Zoom in" |
56
+ | `zoom_out` | "Zoom out" |
57
+ | `delete` | "Delete" |
58
+
59
+ ## Usage
60
+
61
+ ### Basic Usage with Strings
62
+
63
+ ```svelte
64
+ <script lang="ts">
65
+ import { AssetsPreview } from 'stuic';
66
+
67
+ let preview: AssetsPreview;
68
+ const images = [
69
+ '/images/photo1.jpg',
70
+ '/images/photo2.jpg',
71
+ ];
72
+ </script>
73
+
74
+ <button onclick={() => preview.open()}>View Images</button>
75
+
76
+ <AssetsPreview bind:this={preview} assets={images} />
77
+ ```
78
+
79
+ ### With Full Asset Objects
80
+
81
+ ```svelte
82
+ <script lang="ts">
83
+ import { AssetsPreview, type AssetPreview } from 'stuic';
84
+
85
+ let preview: AssetsPreview;
86
+
87
+ const assets: AssetPreview[] = [
88
+ {
89
+ url: {
90
+ thumb: '/images/photo1-thumb.jpg',
91
+ full: '/images/photo1-large.jpg',
92
+ original: '/images/photo1-original.jpg',
93
+ },
94
+ name: 'Vacation Photo',
95
+ type: 'image/jpeg',
96
+ },
97
+ {
98
+ url: {
99
+ thumb: '/docs/report-thumb.png',
100
+ full: '/docs/report.pdf',
101
+ original: '/docs/report.pdf',
102
+ },
103
+ name: 'Annual Report.pdf',
104
+ type: 'application/pdf',
105
+ },
106
+ ];
107
+ </script>
108
+
109
+ <AssetsPreview bind:this={preview} assets={assets} />
110
+ ```
111
+
112
+ ### Open at Specific Index
113
+
114
+ ```svelte
115
+ <script lang="ts">
116
+ let preview: AssetsPreview;
117
+ </script>
118
+
119
+ {#each assets as asset, idx}
120
+ <button onclick={() => preview.open(idx)}>
121
+ <img src={asset.url.thumb} alt={asset.name} />
122
+ </button>
123
+ {/each}
124
+
125
+ <AssetsPreview bind:this={preview} {assets} />
126
+ ```
127
+
128
+ ### With Delete Handler
129
+
130
+ ```svelte
131
+ <script lang="ts">
132
+ let assets = $state([...initialAssets]);
133
+ let preview: AssetsPreview;
134
+
135
+ function handleDelete(asset: AssetPreview, index: number) {
136
+ assets.splice(index, 1);
137
+ assets = assets; // trigger reactivity
138
+ }
139
+ </script>
140
+
141
+ <AssetsPreview
142
+ bind:this={preview}
143
+ {assets}
144
+ onDelete={handleDelete}
145
+ />
146
+ ```
147
+
148
+ ### With Custom Translations
149
+
150
+ ```svelte
151
+ <script lang="ts">
152
+ const t = (key: string) => {
153
+ const translations: Record<string, string> = {
154
+ 'unable_to_preview': 'Vorschau nicht verfügbar',
155
+ 'download': 'Herunterladen',
156
+ 'close': 'Schließen',
157
+ 'zoom_in': 'Vergrößern',
158
+ 'zoom_out': 'Verkleinern',
159
+ 'delete': 'Löschen',
160
+ };
161
+ return translations[key] ?? key;
162
+ };
163
+ </script>
164
+
165
+ <AssetsPreview bind:this={preview} {assets} {t} />
166
+ ```
@@ -0,0 +1 @@
1
+ export { default as AssetsPreview, type Props as AssetsPreviewProps, type AssetPreview, type AssetPreviewUrlObj, } from "./AssetsPreview.svelte";
@@ -0,0 +1 @@
1
+ export { default as AssetsPreview, } from "./AssetsPreview.svelte";
@@ -12,11 +12,7 @@
12
12
  import { iconBsFileEarmarkText } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkText.js";
13
13
  import { iconBsFileEarmarkWord } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkWord.js";
14
14
  import { iconBsFileEarmarkZip } from "@marianmeres/icons-fns/bootstrap/iconBsFileEarmarkZip.js";
15
- import { iconFeatherArrowLeft as iconPrevious } from "@marianmeres/icons-fns/feather/iconFeatherArrowLeft.js";
16
- import { iconFeatherArrowRight as iconNext } from "@marianmeres/icons-fns/feather/iconFeatherArrowRight.js";
17
- import { iconFeatherDownload as iconDownload } from "@marianmeres/icons-fns/feather/iconFeatherDownload.js";
18
15
  import { iconFeatherPlus as iconAdd } from "@marianmeres/icons-fns/feather/iconFeatherPlus.js";
19
- import { iconFeatherTrash2 as iconDelete } from "@marianmeres/icons-fns/feather/iconFeatherTrash2.js";
20
16
  import { onDestroy, type Snippet } from "svelte";
21
17
  import { fileDropzone } from "../../actions/file-dropzone.svelte.js";
22
18
  import { highlightDragover } from "../../actions/highlight-dragover.svelte.js";
@@ -27,20 +23,17 @@
27
23
  type ValidationResult,
28
24
  } from "../../actions/validate.svelte.js";
29
25
  import type { TranslateFn } from "../../types.js";
30
- import { forceDownload } from "../../utils/force-download.js";
31
26
  import { getFileTypeLabel } from "../../utils/get-file-type-label.js";
32
27
  import { getId } from "../../utils/get-id.js";
33
28
  import { isImage } from "../../utils/is-image.js";
34
29
  import { isPlainObject } from "../../utils/is-plain-object.js";
35
- import { preloadImgs } from "../../utils/preload-img.js";
36
30
  import { replaceMap } from "../../utils/replace-map.js";
37
31
  import { twMerge } from "../../utils/tw-merge.js";
32
+ import { AssetsPreview } from "../AssetsPreview/index.js";
38
33
  import Circle from "../Circle/Circle.svelte";
39
- import { Modal } from "../Modal/index.js";
40
34
  import { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
41
35
  import SpinnerCircleOscillate from "../Spinner/SpinnerCircleOscillate.svelte";
42
36
  import { isTHCNotEmpty, type THC } from "../Thc/Thc.svelte";
43
- import { X } from "../X/index.js";
44
37
  import InputWrap from "./_internal/InputWrap.svelte";
45
38
 
46
39
  const clog = createClog("FieldAssets");
@@ -256,8 +249,7 @@
256
249
  let parentHiddenInputEl: HTMLInputElement | undefined = $state();
257
250
  let hasLabel = $derived(isTHCNotEmpty(label) || typeof label === "function");
258
251
  let inputEl = $state<HTMLInputElement>()!;
259
- let modal: Modal = $state()!;
260
- let previewIdx = $state<number>(-1);
252
+ let assetsPreview: AssetsPreview = $state()!;
261
253
 
262
254
  let assets: FieldAsset[] = $derived(parseValue(value));
263
255
  // $inspect("assets", assets);
@@ -269,7 +261,7 @@
269
261
  // $inspect("progress", progress);
270
262
 
271
263
  $effect(() => {
272
- if (!assets?.length) modal?.close?.();
264
+ if (!assets?.length) assetsPreview?.close?.();
273
265
  });
274
266
 
275
267
  //
@@ -317,16 +309,6 @@
317
309
  notifications?.info(t("deleted", { name }));
318
310
  }
319
311
 
320
- function preview_previous() {
321
- previewIdx = (previewIdx - 1 + assets.length) % assets.length;
322
- }
323
-
324
- function preview_next() {
325
- previewIdx = (previewIdx + 1) % assets.length;
326
- }
327
-
328
- const TOP_BUTTON_CLS = "rounded bg-white hover:bg-neutral-200 p-1";
329
-
330
312
  onDestroy(() => {
331
313
  try {
332
314
  assets.forEach((a) => {
@@ -341,39 +323,8 @@
341
323
  }
342
324
  });
343
325
 
344
- // cosmetic side effect - once the modal is open we want to preload all "full" resolutions
345
- // so the navigation feels more instant
346
- $effect(() => {
347
- if (modal.visibility().visible) {
348
- // perhaps we should have some upper limit here...
349
- const toPreload = (assets ?? [])
350
- .map((asset) => {
351
- const url = asset_urls(asset);
352
- return isImage(asset.type ?? url.full) && !url.full.startsWith("blob:")
353
- ? url.full
354
- : "";
355
- })
356
- .filter(Boolean);
357
-
358
- clog.debug("going to (maybe) preload", toPreload);
359
- preloadImgs(toPreload);
360
- }
361
- });
362
326
  </script>
363
327
 
364
- <!-- this must be on window as we're catching any typing anywhere -->
365
- <svelte:window
366
- onkeydown={(e) => {
367
- if (modal.visibility().visible) {
368
- if (["ArrowRight"].includes(e.key)) {
369
- preview_next();
370
- } else if (["ArrowLeft"].includes(e.key)) {
371
- preview_previous();
372
- }
373
- }
374
- }}
375
- />
376
-
377
328
  {#snippet default_render()}
378
329
  {#if isLoading}
379
330
  <div class="p-2 pl-8 flex items-center justify-center min-h-24">
@@ -390,8 +341,7 @@
390
341
  onclick={(e) => {
391
342
  e.stopPropagation();
392
343
  e.preventDefault();
393
- previewIdx = idx;
394
- modal.open();
344
+ assetsPreview.open(idx);
395
345
  }}
396
346
  type="button"
397
347
  >
@@ -562,102 +512,23 @@
562
512
  <!-- hack to be able to validate the conventional way -->
563
513
  <input type="hidden" {name} {value} use:validateAction={() => wrappedValidate} />
564
514
 
565
- <Modal
566
- bind:this={modal}
567
- onEscape={modal?.close}
568
- classBackdrop="p-4 md:p-4"
569
- classInner="max-w-full h-full"
570
- class="max-h-full md:max-h-full"
571
- classMain="flex items-center justify-center relative stuic-field-assets stuic-field-assets-open"
572
- >
573
- {@const previewAsset = assets?.[previewIdx]}
574
- {#if previewAsset}
575
- {@const url = asset_urls(previewAsset!)}
576
- {@const _is_img = isImage(previewAsset!.type ?? url.thumb)}
577
- {#if _is_img}
578
- <img
579
- src={url.full}
580
- class="w-full h-full object-scale-down"
581
- alt={previewAsset?.name}
582
- />
583
- {:else}
584
- <div>
585
- <div>
586
- {@html getAssetIcon((previewAsset?.name ?? "").split(".").at(-1))({
587
- size: 32,
588
- class: "mx-auto",
589
- })}
590
- </div>
591
- <div class="opacity-50 mt-4 text-sm">{t("unable_to_preview")}</div>
592
- </div>
593
- {/if}
594
-
595
- {#if assets?.length > 1}
596
- <div class={["absolute inset-0 flex items-center justify-between"]}>
597
- <button
598
- class={twMerge("p-4", classControls)}
599
- onclick={preview_previous}
600
- type="button"
601
- >
602
- <span class="bg-white rounded-full p-3 block">
603
- {@html iconPrevious()}
604
- </span>
605
- </button>
606
-
607
- <button
608
- class={twMerge("p-4", classControls)}
609
- onclick={preview_next}
610
- type="button"
611
- >
612
- <span class="bg-white rounded-full p-3 block">
613
- {@html iconNext()}
614
- </span>
615
- </button>
616
- </div>
617
- {/if}
618
-
619
- <!-- bg-white rounded-md p-2 -->
620
- <div class="absolute top-4 right-4 flex items-center space-x-3">
621
- <button
622
- class={twMerge(TOP_BUTTON_CLS, classControls)}
623
- onclick={(e) => {
624
- e.preventDefault();
625
- remove_by_idx(previewIdx);
626
- previewIdx = previewIdx % assets.length; // important
627
- }}
628
- type="button"
629
- aria-label={t("delete")}
630
- use:tooltip
631
- >
632
- {@html iconDelete({ class: "size-6" })}
633
- </button>
634
- <button
635
- class={twMerge(TOP_BUTTON_CLS, classControls)}
636
- type="button"
637
- onclick={(e) => {
638
- e.preventDefault();
639
- forceDownload(url.original ?? url.full, previewAsset?.name || "");
640
- }}
641
- aria-label={t("download")}
642
- use:tooltip
643
- >
644
- {@html iconDownload({ class: "size-6" })}
645
- </button>
646
- <button
647
- class={twMerge(TOP_BUTTON_CLS, classControls)}
648
- onclick={modal.close}
649
- aria-label={t("close")}
650
- type="button"
651
- use:tooltip
652
- >
653
- <X />
654
- </button>
655
- </div>
656
-
657
- {#if previewAsset?.name}
658
- <span class="absolute top-4 left-4 bg-white px-1 text-sm rounded">
659
- {previewAsset?.name}
660
- </span>
661
- {/if}
662
- {/if}
663
- </Modal>
515
+ <AssetsPreview
516
+ bind:this={assetsPreview}
517
+ assets={assets.map((a) => {
518
+ const urls = asset_urls(a);
519
+ return {
520
+ url: {
521
+ thumb: urls.thumb,
522
+ full: urls.full,
523
+ original: urls.original ?? urls.full,
524
+ },
525
+ name: a.name,
526
+ type: a.type,
527
+ };
528
+ })}
529
+ {t}
530
+ {classControls}
531
+ onDelete={(_, index) => {
532
+ remove_by_idx(index);
533
+ }}
534
+ />
@@ -8,7 +8,7 @@
8
8
  <script lang="ts">
9
9
  import { twMerge } from "../../index.js";
10
10
 
11
- let { class: classProps, strokeWidth = 2 }: Props = $props();
11
+ let { class: classProps, strokeWidth = 2.5 }: Props = $props();
12
12
 
13
13
  // size-6 = 1.5rem = 24px
14
14
  </script>
package/dist/index.d.ts CHANGED
@@ -24,6 +24,7 @@
24
24
  export * from "./components/AlertConfirmPrompt/index.js";
25
25
  export * from "./components/AnimatedElipsis/index.js";
26
26
  export * from "./components/AppShell/index.js";
27
+ export * from "./components/AssetsPreview/index.js";
27
28
  export * from "./components/AvatarInitials/index.js";
28
29
  export * from "./components/Backdrop/index.js";
29
30
  export * from "./components/Button/index.js";
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@
25
25
  export * from "./components/AlertConfirmPrompt/index.js";
26
26
  export * from "./components/AnimatedElipsis/index.js";
27
27
  export * from "./components/AppShell/index.js";
28
+ export * from "./components/AssetsPreview/index.js";
28
29
  export * from "./components/AvatarInitials/index.js";
29
30
  export * from "./components/Backdrop/index.js";
30
31
  export * from "./components/Button/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.39.1",
3
+ "version": "2.41.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",