@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.
- package/dist/components/AssetsPreview/AssetsPreview.svelte +547 -0
- package/dist/components/AssetsPreview/AssetsPreview.svelte.d.ts +32 -0
- package/dist/components/AssetsPreview/README.md +166 -0
- package/dist/components/AssetsPreview/index.d.ts +1 -0
- package/dist/components/AssetsPreview/index.js +1 -0
- package/dist/components/Input/FieldAssets.svelte +24 -153
- package/dist/components/X/X.svelte +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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
|
-
<
|
|
566
|
-
bind:this={
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
+
/>
|
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";
|