@gradio/gallery 0.18.0 → 0.19.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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @gradio/gallery
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Features
6
+
7
+ - [#13566](https://github.com/gradio-app/gradio/pull/13566) [`79b9171`](https://github.com/gradio-app/gradio/commit/79b91718b5c972c174ed042d8b04443bc2274076) - Gallery Download All. Thanks @dawoodkhan82!
8
+ - [#13543](https://github.com/gradio-app/gradio/pull/13543) [`0533483`](https://github.com/gradio-app/gradio/commit/0533483bccdee38f334a598f18297e8c02966343) - Migrate Image components to Svelte 5. Thanks @dawoodkhan82!
9
+
10
+ ### Dependency updates
11
+
12
+ - @gradio/statustracker@0.15.1
13
+ - @gradio/icons@0.16.0
14
+ - @gradio/atoms@0.26.0
15
+ - @gradio/image@0.28.0
16
+ - @gradio/video@0.22.0
17
+ - @gradio/client@2.3.1
18
+ - @gradio/upload@0.18.1
19
+ - @gradio/file@0.15.1
20
+
3
21
  ## 0.18.0
4
22
 
5
23
  ### Features
package/Gallery.test.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import { test, describe, afterEach, expect, vi } from "vitest";
2
- import { cleanup, render, fireEvent, waitFor } from "@self/tootils/render";
2
+ import {
3
+ cleanup,
4
+ render,
5
+ fireEvent,
6
+ waitFor,
7
+ upload_file,
8
+ mock_client,
9
+ TEST_JPG
10
+ } from "@self/tootils/render";
3
11
  import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
4
12
 
5
13
  import Gallery from "./Index.svelte";
@@ -376,6 +384,84 @@ describe("Props: buttons", () => {
376
384
  expect(getByRole("button", { name: "common.download" })).toBeVisible();
377
385
  });
378
386
 
387
+ test("buttons=['download_all'] downloads gallery files as a zip from grid view", async () => {
388
+ const fetch = vi.fn(async () => new Response(new Blob(["file"])));
389
+ const blobs: Blob[] = [];
390
+ const clicked_names: string[] = [];
391
+ const create_object_url = vi
392
+ .spyOn(URL, "createObjectURL")
393
+ .mockImplementation((object: Blob | MediaSource) => {
394
+ blobs.push(object as Blob);
395
+ return "blob:gallery";
396
+ });
397
+ const revoke_object_url = vi
398
+ .spyOn(URL, "revokeObjectURL")
399
+ .mockImplementation(() => {});
400
+ const click = vi
401
+ .spyOn(HTMLAnchorElement.prototype, "click")
402
+ .mockImplementation(function (this: HTMLAnchorElement) {
403
+ clicked_names.push(this.download);
404
+ });
405
+
406
+ try {
407
+ const value = [img("cat"), vid("clip"), img("dog")];
408
+ const { queryByRole } = await render(Gallery, {
409
+ ...preview_props,
410
+ value,
411
+ buttons: ["download_all"],
412
+ client: { fetch }
413
+ });
414
+
415
+ expect(
416
+ queryByRole("button", { name: "common.download_all" })
417
+ ).not.toBeInTheDocument();
418
+
419
+ cleanup();
420
+
421
+ const { getByRole } = await render(Gallery, {
422
+ ...default_props,
423
+ value,
424
+ buttons: ["download_all"],
425
+ client: { fetch }
426
+ });
427
+
428
+ await fireEvent.click(
429
+ getByRole("button", { name: "common.download_all" })
430
+ );
431
+
432
+ await waitFor(() => {
433
+ expect(fetch).toHaveBeenCalledTimes(3);
434
+ expect(click).toHaveBeenCalledTimes(1);
435
+ });
436
+
437
+ expect(fetch).toHaveBeenNthCalledWith(1, "https://example.com/cat.png");
438
+ expect(fetch).toHaveBeenNthCalledWith(2, "https://example.com/clip.mp4");
439
+ expect(fetch).toHaveBeenNthCalledWith(3, "https://example.com/dog.png");
440
+ expect(create_object_url).toHaveBeenCalledTimes(1);
441
+ expect(revoke_object_url).toHaveBeenCalledWith("blob:gallery");
442
+ expect(clicked_names).toEqual(["gallery.zip"]);
443
+ expect(blobs[0].type).toBe("application/zip");
444
+
445
+ const zip = new Uint8Array(await blobs[0].arrayBuffer());
446
+ expect(Array.from(zip.slice(0, 4))).toEqual([0x50, 0x4b, 0x03, 0x04]);
447
+ const zip_view = new DataView(zip.buffer);
448
+ expect(zip_view.getUint16(6, true)).toBe(0x0800);
449
+ const central_header_index = zip.findIndex((_, index) =>
450
+ [0x50, 0x4b, 0x01, 0x02].every(
451
+ (byte, offset) => zip[index + offset] === byte
452
+ )
453
+ );
454
+ expect(central_header_index).toBeGreaterThan(-1);
455
+ expect(zip_view.getUint16(central_header_index + 8, true)).toBe(0x0800);
456
+ const zip_text = new TextDecoder().decode(zip);
457
+ expect(zip_text).toContain("cat.png");
458
+ expect(zip_text).toContain("clip.mp4");
459
+ expect(zip_text).toContain("dog.png");
460
+ } finally {
461
+ vi.restoreAllMocks();
462
+ }
463
+ });
464
+
379
465
  test("buttons=['fullscreen'] shows fullscreen button in preview", async () => {
380
466
  const { getByLabelText } = await render(Gallery, {
381
467
  ...preview_props,
@@ -610,6 +696,30 @@ describe("Events: change", () => {
610
696
  });
611
697
  });
612
698
 
699
+ describe("Events: upload", () => {
700
+ afterEach(() => cleanup());
701
+
702
+ test("upload: file input dispatches upload and change events", async () => {
703
+ const { listen } = await render(Gallery, {
704
+ ...default_props,
705
+ interactive: true,
706
+ value: null,
707
+ root: "https://example.com",
708
+ client: mock_client()
709
+ });
710
+
711
+ const upload = listen("upload");
712
+ const change = listen("change");
713
+
714
+ await upload_file(TEST_JPG);
715
+
716
+ await waitFor(() => {
717
+ expect(upload).toHaveBeenCalledTimes(1);
718
+ });
719
+ expect(change).toHaveBeenCalledTimes(1);
720
+ });
721
+ });
722
+
613
723
  describe("Events: select", () => {
614
724
  afterEach(() => cleanup());
615
725
 
package/Index.svelte CHANGED
@@ -205,10 +205,10 @@
205
205
  <Webcam
206
206
  root={gradio.shared.root}
207
207
  value={null}
208
- on:capture={async (e) => {
209
- if (!(e.detail instanceof Blob)) return;
208
+ oncapture={async (detail) => {
209
+ if (!(detail instanceof Blob)) return;
210
210
  const f = await handle_save(
211
- e.detail,
211
+ detail,
212
212
  (f) => gradio.shared.client.upload(f, gradio.shared.root),
213
213
  "webcam_upload"
214
214
  );
@@ -217,9 +217,6 @@
217
217
  active_source = null;
218
218
  gradio.dispatch("change", gradio.props.value);
219
219
  }}
220
- on:error
221
- on:drag
222
- on:close_stream
223
220
  mirror_webcam={true}
224
221
  streaming={false}
225
222
  mode="image"
@@ -231,18 +228,15 @@
231
228
  <Webcam
232
229
  root={gradio.shared.root}
233
230
  value={null}
234
- on:capture={async (e) => {
235
- if (!e.detail) return;
236
- const f = { ...(e.detail as FileData) };
231
+ oncapture={async (detail) => {
232
+ if (!detail) return;
233
+ const f = { ...(detail as FileData) };
237
234
  f.mime_type = "video/webm";
238
235
  const processed_files = await process_upload_files([f]);
239
236
  gradio.props.value?.push(...processed_files);
240
237
  active_source = null;
241
238
  gradio.dispatch("change", gradio.props.value);
242
239
  }}
243
- on:error
244
- on:drag
245
- on:close_stream
246
240
  mirror_webcam={true}
247
241
  streaming={false}
248
242
  mode="video"
@@ -302,6 +296,9 @@
302
296
  show_download_button={gradio.props.buttons.some(
303
297
  (btn) => typeof btn === "string" && btn === "download"
304
298
  )}
299
+ show_download_all_button={gradio.props.buttons.some(
300
+ (btn) => typeof btn === "string" && btn === "download_all"
301
+ )}
305
302
  fit_columns={gradio.props.fit_columns}
306
303
  i18n={gradio.i18n}
307
304
  _fetch={(...args) => gradio.shared.client.fetch(...args)}
package/dist/Index.svelte CHANGED
@@ -205,10 +205,10 @@
205
205
  <Webcam
206
206
  root={gradio.shared.root}
207
207
  value={null}
208
- on:capture={async (e) => {
209
- if (!(e.detail instanceof Blob)) return;
208
+ oncapture={async (detail) => {
209
+ if (!(detail instanceof Blob)) return;
210
210
  const f = await handle_save(
211
- e.detail,
211
+ detail,
212
212
  (f) => gradio.shared.client.upload(f, gradio.shared.root),
213
213
  "webcam_upload"
214
214
  );
@@ -217,9 +217,6 @@
217
217
  active_source = null;
218
218
  gradio.dispatch("change", gradio.props.value);
219
219
  }}
220
- on:error
221
- on:drag
222
- on:close_stream
223
220
  mirror_webcam={true}
224
221
  streaming={false}
225
222
  mode="image"
@@ -231,18 +228,15 @@
231
228
  <Webcam
232
229
  root={gradio.shared.root}
233
230
  value={null}
234
- on:capture={async (e) => {
235
- if (!e.detail) return;
236
- const f = { ...(e.detail as FileData) };
231
+ oncapture={async (detail) => {
232
+ if (!detail) return;
233
+ const f = { ...(detail as FileData) };
237
234
  f.mime_type = "video/webm";
238
235
  const processed_files = await process_upload_files([f]);
239
236
  gradio.props.value?.push(...processed_files);
240
237
  active_source = null;
241
238
  gradio.dispatch("change", gradio.props.value);
242
239
  }}
243
- on:error
244
- on:drag
245
- on:close_stream
246
240
  mirror_webcam={true}
247
241
  streaming={false}
248
242
  mode="video"
@@ -302,6 +296,9 @@
302
296
  show_download_button={gradio.props.buttons.some(
303
297
  (btn) => typeof btn === "string" && btn === "download"
304
298
  )}
299
+ show_download_all_button={gradio.props.buttons.some(
300
+ (btn) => typeof btn === "string" && btn === "download_all"
301
+ )}
305
302
  fit_columns={gradio.props.fit_columns}
306
303
  i18n={gradio.i18n}
307
304
  _fetch={(...args) => gradio.shared.client.fetch(...args)}
@@ -1,24 +1,5 @@
1
1
  export { default as BaseGallery } from "./shared/Gallery.svelte";
2
2
  export { default as BaseExample } from "./Example.svelte";
3
- interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
4
- new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
5
- $$bindings?: Bindings;
6
- } & Exports;
7
- (internal: unknown, props: Props & {
8
- $$events?: Events;
9
- $$slots?: Slots;
10
- }): Exports & {
11
- $set?: any;
12
- $on?: any;
13
- };
14
- z_$$bindings?: Bindings;
15
- }
16
- declare const Index: $$__sveltets_2_IsomorphicComponent<$$ComponentProps, {
17
- error: CustomEvent<string>;
18
- drag: CustomEvent<any>;
19
- close_stream: CustomEvent<undefined>;
20
- } & {
21
- [evt: string]: CustomEvent<any>;
22
- }, {}, {}, "">;
23
- type Index = InstanceType<typeof Index>;
3
+ declare const Index: import("svelte").Component<$$ComponentProps, {}, "">;
4
+ type Index = ReturnType<typeof Index>;
24
5
  export default Index;
@@ -18,6 +18,7 @@
18
18
 
19
19
  import {
20
20
  Download,
21
+ DownloadAll,
21
22
  Image as ImageIcon,
22
23
  Clear,
23
24
  Play,
@@ -45,6 +46,7 @@
45
46
  object_fit = "cover",
46
47
  show_share_button = false,
47
48
  show_download_button = false,
49
+ show_download_all_button = false,
48
50
  i18n,
49
51
  selected_index = $bindable(),
50
52
  interactive,
@@ -83,6 +85,7 @@
83
85
  object_fit: "contain" | "cover" | "fill" | "none" | "scale-down";
84
86
  show_share_button: boolean;
85
87
  show_download_button: boolean;
88
+ show_download_all_button: boolean;
86
89
  i18n: I18nFormatter;
87
90
  selected_index: number | null;
88
91
  interactive: boolean;
@@ -307,6 +310,184 @@
307
310
  URL.revokeObjectURL(url);
308
311
  }
309
312
 
313
+ const crc_table = new Uint32Array(
314
+ Array.from({ length: 256 }, (_, i) => {
315
+ let c = i;
316
+ for (let k = 0; k < 8; k++) {
317
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
318
+ }
319
+ return c >>> 0;
320
+ })
321
+ );
322
+
323
+ function crc32(data: Uint8Array): number {
324
+ let c = 0xffffffff;
325
+ for (const byte of data) {
326
+ c = crc_table[(c ^ byte) & 0xff] ^ (c >>> 8);
327
+ }
328
+ return (c ^ 0xffffffff) >>> 0;
329
+ }
330
+
331
+ function write_uint16(view: DataView, offset: number, value: number): void {
332
+ view.setUint16(offset, value, true);
333
+ }
334
+
335
+ function write_uint32(view: DataView, offset: number, value: number): void {
336
+ view.setUint32(offset, value, true);
337
+ }
338
+
339
+ function zip_header(size: number): Uint8Array {
340
+ return new Uint8Array(size);
341
+ }
342
+
343
+ function get_unique_name(
344
+ file: FileData,
345
+ fallback: string,
346
+ used_names: Map<string, number>
347
+ ): string {
348
+ const raw_name = file.orig_name || file.path || fallback;
349
+ const name = raw_name.split(/[/\\]/).pop() || fallback;
350
+ const count = used_names.get(name) ?? 0;
351
+ used_names.set(name, count + 1);
352
+ if (count === 0) return name;
353
+
354
+ const dot_index = name.lastIndexOf(".");
355
+ const base = dot_index > 0 ? name.slice(0, dot_index) : name;
356
+ const ext = dot_index > 0 ? name.slice(dot_index) : "";
357
+ return `${base}-${count + 1}${ext}`;
358
+ }
359
+
360
+ function create_zip(files: { name: string; data: Uint8Array }[]): Uint8Array {
361
+ const encoder = new TextEncoder();
362
+ const parts: Uint8Array[] = [];
363
+ const central_parts: Uint8Array[] = [];
364
+ const utf8_flag = 0x0800;
365
+ let offset = 0;
366
+
367
+ for (const file of files) {
368
+ const name = encoder.encode(file.name);
369
+ const checksum = crc32(file.data);
370
+ const local_header = zip_header(30 + name.length);
371
+ const local_view = new DataView(local_header.buffer);
372
+ write_uint32(local_view, 0, 0x04034b50);
373
+ write_uint16(local_view, 4, 20);
374
+ write_uint16(local_view, 6, utf8_flag);
375
+ write_uint16(local_view, 8, 0);
376
+ write_uint16(local_view, 10, 0);
377
+ write_uint16(local_view, 12, 33);
378
+ write_uint32(local_view, 14, checksum);
379
+ write_uint32(local_view, 18, file.data.length);
380
+ write_uint32(local_view, 22, file.data.length);
381
+ write_uint16(local_view, 26, name.length);
382
+ write_uint16(local_view, 28, 0);
383
+ local_header.set(name, 30);
384
+ parts.push(local_header, file.data);
385
+
386
+ const central_header = zip_header(46 + name.length);
387
+ const central_view = new DataView(central_header.buffer);
388
+ write_uint32(central_view, 0, 0x02014b50);
389
+ write_uint16(central_view, 4, 20);
390
+ write_uint16(central_view, 6, 20);
391
+ write_uint16(central_view, 8, utf8_flag);
392
+ write_uint16(central_view, 10, 0);
393
+ write_uint16(central_view, 12, 0);
394
+ write_uint16(central_view, 14, 33);
395
+ write_uint32(central_view, 16, checksum);
396
+ write_uint32(central_view, 20, file.data.length);
397
+ write_uint32(central_view, 24, file.data.length);
398
+ write_uint16(central_view, 28, name.length);
399
+ write_uint16(central_view, 30, 0);
400
+ write_uint16(central_view, 32, 0);
401
+ write_uint16(central_view, 34, 0);
402
+ write_uint16(central_view, 36, 0);
403
+ write_uint32(central_view, 38, 0);
404
+ write_uint32(central_view, 42, offset);
405
+ central_header.set(name, 46);
406
+ central_parts.push(central_header);
407
+
408
+ offset += local_header.length + file.data.length;
409
+ }
410
+
411
+ const central_offset = offset;
412
+ const central_size = central_parts.reduce(
413
+ (sum, part) => sum + part.length,
414
+ 0
415
+ );
416
+ const end = zip_header(22);
417
+ const end_view = new DataView(end.buffer);
418
+ write_uint32(end_view, 0, 0x06054b50);
419
+ write_uint16(end_view, 4, 0);
420
+ write_uint16(end_view, 6, 0);
421
+ write_uint16(end_view, 8, files.length);
422
+ write_uint16(end_view, 10, files.length);
423
+ write_uint32(end_view, 12, central_size);
424
+ write_uint32(end_view, 16, central_offset);
425
+ write_uint16(end_view, 20, 0);
426
+
427
+ const zip = new Uint8Array(central_offset + central_size + end.length);
428
+ let cursor = 0;
429
+ for (const part of [...parts, ...central_parts, end]) {
430
+ zip.set(part, cursor);
431
+ cursor += part.length;
432
+ }
433
+ return zip;
434
+ }
435
+
436
+ async function get_zip_file(
437
+ url: string,
438
+ name: string
439
+ ): Promise<{ name: string; data: Uint8Array } | null> {
440
+ let response;
441
+ try {
442
+ response = await _fetch(url);
443
+ } catch (error) {
444
+ if (error instanceof TypeError) {
445
+ window.open(url, "_blank", "noreferrer");
446
+ }
447
+ return null;
448
+ }
449
+ if (!response.ok) return null;
450
+ return {
451
+ name,
452
+ data: new Uint8Array(await response.arrayBuffer())
453
+ };
454
+ }
455
+
456
+ async function download_all(media: GalleryData[]): Promise<void> {
457
+ const used_names = new Map<string, number>();
458
+ const files = await Promise.all(
459
+ media.flatMap((item, index) => {
460
+ const file = "image" in item ? item.image : item.video;
461
+ const fallback =
462
+ "image" in item ? `image-${index + 1}` : `video-${index + 1}`;
463
+ if (!file.url) return [];
464
+ const name = get_unique_name(file, fallback, used_names);
465
+ return [get_zip_file(file.url, name)];
466
+ })
467
+ );
468
+ const valid_files = files.filter(
469
+ (file): file is { name: string; data: Uint8Array } => file !== null
470
+ );
471
+
472
+ if (valid_files.length === 0) return;
473
+
474
+ const zip = create_zip(valid_files);
475
+ const url = URL.createObjectURL(
476
+ new Blob([zip.buffer as ArrayBuffer], { type: "application/zip" })
477
+ );
478
+ const link = document.createElement("a");
479
+ link.href = url;
480
+ link.download = "gallery.zip";
481
+ link.click();
482
+ URL.revokeObjectURL(url);
483
+ }
484
+
485
+ function handle_download_all(): void {
486
+ if (resolved_value) {
487
+ download_all(resolved_value);
488
+ }
489
+ }
490
+
310
491
  let selected_media = $derived.by(() =>
311
492
  selected_index != null && resolved_value != null
312
493
  ? resolved_value[selected_index]
@@ -418,11 +599,11 @@
418
599
  <div class="icon-button">
419
600
  <ShareButton
420
601
  {i18n}
421
- on:share={(detail) => {
422
- onshare(detail);
602
+ onshare={(detail) => {
603
+ onshare({ detail } as CustomEvent);
423
604
  }}
424
- on:error={(detail) => {
425
- onerror(detail);
605
+ onerror={(detail) => {
606
+ onerror({ detail } as CustomEvent);
426
607
  }}
427
608
  value={resolved_value}
428
609
  formatter={format_gallery_for_sharing}
@@ -550,6 +731,13 @@
550
731
  onclear();
551
732
  }}
552
733
  >
734
+ {#if show_download_all_button}
735
+ <IconButton
736
+ Icon={DownloadAll}
737
+ label={i18n("common.download_all")}
738
+ onclick={handle_download_all}
739
+ />
740
+ {/if}
553
741
  {#if upload && stream_handler}
554
742
  <IconButton
555
743
  Icon={UploadIcon}
@@ -598,6 +786,14 @@
598
786
  />
599
787
  {/if}
600
788
  </ModifyUpload>
789
+ {:else if show_download_all_button && !(selected_media && allow_preview)}
790
+ <IconButtonWrapper>
791
+ <IconButton
792
+ Icon={DownloadAll}
793
+ label={i18n("common.download_all")}
794
+ onclick={handle_download_all}
795
+ />
796
+ </IconButtonWrapper>
601
797
  {/if}
602
798
  <div
603
799
  class="grid-container"
@@ -16,6 +16,7 @@ type $$ComponentProps = {
16
16
  object_fit: "contain" | "cover" | "fill" | "none" | "scale-down";
17
17
  show_share_button: boolean;
18
18
  show_download_button: boolean;
19
+ show_download_all_button: boolean;
19
20
  i18n: I18nFormatter;
20
21
  selected_index: number | null;
21
22
  interactive: boolean;
package/dist/types.d.ts CHANGED
@@ -19,7 +19,7 @@ export interface GalleryProps {
19
19
  allow_preview: boolean;
20
20
  selected_index: number | null;
21
21
  object_fit: "contain" | "cover" | "fill" | "none" | "scale-down";
22
- buttons: (string | CustomButton)[];
22
+ buttons: ("share" | "download" | "download_all" | "fullscreen" | CustomButton)[];
23
23
  type: "numpy" | "pil" | "filepath";
24
24
  fit_columns: boolean;
25
25
  sources: ("upload" | "webcam-video" | "webcam" | "clipboard")[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradio/gallery",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Gradio UI packages",
5
5
  "type": "module",
6
6
  "author": "",
@@ -8,15 +8,15 @@
8
8
  "private": false,
9
9
  "dependencies": {
10
10
  "dequal": "^2.0.3",
11
- "@gradio/atoms": "^0.25.0",
12
- "@gradio/file": "^0.15.0",
13
- "@gradio/icons": "^0.15.1",
14
- "@gradio/client": "^2.3.0",
15
- "@gradio/image": "^0.27.0",
16
- "@gradio/statustracker": "^0.15.0",
11
+ "@gradio/atoms": "^0.26.0",
12
+ "@gradio/client": "^2.3.1",
13
+ "@gradio/file": "^0.15.1",
14
+ "@gradio/icons": "^0.16.0",
15
+ "@gradio/image": "^0.28.0",
16
+ "@gradio/statustracker": "^0.15.1",
17
+ "@gradio/upload": "^0.18.1",
17
18
  "@gradio/utils": "^0.13.0",
18
- "@gradio/upload": "^0.18.0",
19
- "@gradio/video": "^0.21.0"
19
+ "@gradio/video": "^0.22.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@gradio/preview": "^0.16.2"
@@ -18,6 +18,7 @@
18
18
 
19
19
  import {
20
20
  Download,
21
+ DownloadAll,
21
22
  Image as ImageIcon,
22
23
  Clear,
23
24
  Play,
@@ -45,6 +46,7 @@
45
46
  object_fit = "cover",
46
47
  show_share_button = false,
47
48
  show_download_button = false,
49
+ show_download_all_button = false,
48
50
  i18n,
49
51
  selected_index = $bindable(),
50
52
  interactive,
@@ -83,6 +85,7 @@
83
85
  object_fit: "contain" | "cover" | "fill" | "none" | "scale-down";
84
86
  show_share_button: boolean;
85
87
  show_download_button: boolean;
88
+ show_download_all_button: boolean;
86
89
  i18n: I18nFormatter;
87
90
  selected_index: number | null;
88
91
  interactive: boolean;
@@ -307,6 +310,184 @@
307
310
  URL.revokeObjectURL(url);
308
311
  }
309
312
 
313
+ const crc_table = new Uint32Array(
314
+ Array.from({ length: 256 }, (_, i) => {
315
+ let c = i;
316
+ for (let k = 0; k < 8; k++) {
317
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
318
+ }
319
+ return c >>> 0;
320
+ })
321
+ );
322
+
323
+ function crc32(data: Uint8Array): number {
324
+ let c = 0xffffffff;
325
+ for (const byte of data) {
326
+ c = crc_table[(c ^ byte) & 0xff] ^ (c >>> 8);
327
+ }
328
+ return (c ^ 0xffffffff) >>> 0;
329
+ }
330
+
331
+ function write_uint16(view: DataView, offset: number, value: number): void {
332
+ view.setUint16(offset, value, true);
333
+ }
334
+
335
+ function write_uint32(view: DataView, offset: number, value: number): void {
336
+ view.setUint32(offset, value, true);
337
+ }
338
+
339
+ function zip_header(size: number): Uint8Array {
340
+ return new Uint8Array(size);
341
+ }
342
+
343
+ function get_unique_name(
344
+ file: FileData,
345
+ fallback: string,
346
+ used_names: Map<string, number>
347
+ ): string {
348
+ const raw_name = file.orig_name || file.path || fallback;
349
+ const name = raw_name.split(/[/\\]/).pop() || fallback;
350
+ const count = used_names.get(name) ?? 0;
351
+ used_names.set(name, count + 1);
352
+ if (count === 0) return name;
353
+
354
+ const dot_index = name.lastIndexOf(".");
355
+ const base = dot_index > 0 ? name.slice(0, dot_index) : name;
356
+ const ext = dot_index > 0 ? name.slice(dot_index) : "";
357
+ return `${base}-${count + 1}${ext}`;
358
+ }
359
+
360
+ function create_zip(files: { name: string; data: Uint8Array }[]): Uint8Array {
361
+ const encoder = new TextEncoder();
362
+ const parts: Uint8Array[] = [];
363
+ const central_parts: Uint8Array[] = [];
364
+ const utf8_flag = 0x0800;
365
+ let offset = 0;
366
+
367
+ for (const file of files) {
368
+ const name = encoder.encode(file.name);
369
+ const checksum = crc32(file.data);
370
+ const local_header = zip_header(30 + name.length);
371
+ const local_view = new DataView(local_header.buffer);
372
+ write_uint32(local_view, 0, 0x04034b50);
373
+ write_uint16(local_view, 4, 20);
374
+ write_uint16(local_view, 6, utf8_flag);
375
+ write_uint16(local_view, 8, 0);
376
+ write_uint16(local_view, 10, 0);
377
+ write_uint16(local_view, 12, 33);
378
+ write_uint32(local_view, 14, checksum);
379
+ write_uint32(local_view, 18, file.data.length);
380
+ write_uint32(local_view, 22, file.data.length);
381
+ write_uint16(local_view, 26, name.length);
382
+ write_uint16(local_view, 28, 0);
383
+ local_header.set(name, 30);
384
+ parts.push(local_header, file.data);
385
+
386
+ const central_header = zip_header(46 + name.length);
387
+ const central_view = new DataView(central_header.buffer);
388
+ write_uint32(central_view, 0, 0x02014b50);
389
+ write_uint16(central_view, 4, 20);
390
+ write_uint16(central_view, 6, 20);
391
+ write_uint16(central_view, 8, utf8_flag);
392
+ write_uint16(central_view, 10, 0);
393
+ write_uint16(central_view, 12, 0);
394
+ write_uint16(central_view, 14, 33);
395
+ write_uint32(central_view, 16, checksum);
396
+ write_uint32(central_view, 20, file.data.length);
397
+ write_uint32(central_view, 24, file.data.length);
398
+ write_uint16(central_view, 28, name.length);
399
+ write_uint16(central_view, 30, 0);
400
+ write_uint16(central_view, 32, 0);
401
+ write_uint16(central_view, 34, 0);
402
+ write_uint16(central_view, 36, 0);
403
+ write_uint32(central_view, 38, 0);
404
+ write_uint32(central_view, 42, offset);
405
+ central_header.set(name, 46);
406
+ central_parts.push(central_header);
407
+
408
+ offset += local_header.length + file.data.length;
409
+ }
410
+
411
+ const central_offset = offset;
412
+ const central_size = central_parts.reduce(
413
+ (sum, part) => sum + part.length,
414
+ 0
415
+ );
416
+ const end = zip_header(22);
417
+ const end_view = new DataView(end.buffer);
418
+ write_uint32(end_view, 0, 0x06054b50);
419
+ write_uint16(end_view, 4, 0);
420
+ write_uint16(end_view, 6, 0);
421
+ write_uint16(end_view, 8, files.length);
422
+ write_uint16(end_view, 10, files.length);
423
+ write_uint32(end_view, 12, central_size);
424
+ write_uint32(end_view, 16, central_offset);
425
+ write_uint16(end_view, 20, 0);
426
+
427
+ const zip = new Uint8Array(central_offset + central_size + end.length);
428
+ let cursor = 0;
429
+ for (const part of [...parts, ...central_parts, end]) {
430
+ zip.set(part, cursor);
431
+ cursor += part.length;
432
+ }
433
+ return zip;
434
+ }
435
+
436
+ async function get_zip_file(
437
+ url: string,
438
+ name: string
439
+ ): Promise<{ name: string; data: Uint8Array } | null> {
440
+ let response;
441
+ try {
442
+ response = await _fetch(url);
443
+ } catch (error) {
444
+ if (error instanceof TypeError) {
445
+ window.open(url, "_blank", "noreferrer");
446
+ }
447
+ return null;
448
+ }
449
+ if (!response.ok) return null;
450
+ return {
451
+ name,
452
+ data: new Uint8Array(await response.arrayBuffer())
453
+ };
454
+ }
455
+
456
+ async function download_all(media: GalleryData[]): Promise<void> {
457
+ const used_names = new Map<string, number>();
458
+ const files = await Promise.all(
459
+ media.flatMap((item, index) => {
460
+ const file = "image" in item ? item.image : item.video;
461
+ const fallback =
462
+ "image" in item ? `image-${index + 1}` : `video-${index + 1}`;
463
+ if (!file.url) return [];
464
+ const name = get_unique_name(file, fallback, used_names);
465
+ return [get_zip_file(file.url, name)];
466
+ })
467
+ );
468
+ const valid_files = files.filter(
469
+ (file): file is { name: string; data: Uint8Array } => file !== null
470
+ );
471
+
472
+ if (valid_files.length === 0) return;
473
+
474
+ const zip = create_zip(valid_files);
475
+ const url = URL.createObjectURL(
476
+ new Blob([zip.buffer as ArrayBuffer], { type: "application/zip" })
477
+ );
478
+ const link = document.createElement("a");
479
+ link.href = url;
480
+ link.download = "gallery.zip";
481
+ link.click();
482
+ URL.revokeObjectURL(url);
483
+ }
484
+
485
+ function handle_download_all(): void {
486
+ if (resolved_value) {
487
+ download_all(resolved_value);
488
+ }
489
+ }
490
+
310
491
  let selected_media = $derived.by(() =>
311
492
  selected_index != null && resolved_value != null
312
493
  ? resolved_value[selected_index]
@@ -418,11 +599,11 @@
418
599
  <div class="icon-button">
419
600
  <ShareButton
420
601
  {i18n}
421
- on:share={(detail) => {
422
- onshare(detail);
602
+ onshare={(detail) => {
603
+ onshare({ detail } as CustomEvent);
423
604
  }}
424
- on:error={(detail) => {
425
- onerror(detail);
605
+ onerror={(detail) => {
606
+ onerror({ detail } as CustomEvent);
426
607
  }}
427
608
  value={resolved_value}
428
609
  formatter={format_gallery_for_sharing}
@@ -550,6 +731,13 @@
550
731
  onclear();
551
732
  }}
552
733
  >
734
+ {#if show_download_all_button}
735
+ <IconButton
736
+ Icon={DownloadAll}
737
+ label={i18n("common.download_all")}
738
+ onclick={handle_download_all}
739
+ />
740
+ {/if}
553
741
  {#if upload && stream_handler}
554
742
  <IconButton
555
743
  Icon={UploadIcon}
@@ -598,6 +786,14 @@
598
786
  />
599
787
  {/if}
600
788
  </ModifyUpload>
789
+ {:else if show_download_all_button && !(selected_media && allow_preview)}
790
+ <IconButtonWrapper>
791
+ <IconButton
792
+ Icon={DownloadAll}
793
+ label={i18n("common.download_all")}
794
+ onclick={handle_download_all}
795
+ />
796
+ </IconButtonWrapper>
601
797
  {/if}
602
798
  <div
603
799
  class="grid-container"
package/types.ts CHANGED
@@ -23,7 +23,13 @@ export interface GalleryProps {
23
23
  allow_preview: boolean;
24
24
  selected_index: number | null;
25
25
  object_fit: "contain" | "cover" | "fill" | "none" | "scale-down";
26
- buttons: (string | CustomButton)[];
26
+ buttons: (
27
+ | "share"
28
+ | "download"
29
+ | "download_all"
30
+ | "fullscreen"
31
+ | CustomButton
32
+ )[];
27
33
  type: "numpy" | "pil" | "filepath";
28
34
  fit_columns: boolean;
29
35
  sources: ("upload" | "webcam-video" | "webcam" | "clipboard")[];