Spaces:
Runtime error
Runtime error
| <script lang="ts"> | |
| import { BlockLabel, Empty, ShareButton } from "@gradio/atoms"; | |
| import { ModifyUpload } from "@gradio/upload"; | |
| import type { SelectData } from "@gradio/utils"; | |
| import { Image } from "@gradio/image/shared"; | |
| import { dequal } from "dequal"; | |
| import { createEventDispatcher, getContext } from "svelte"; | |
| import { tick } from "svelte"; | |
| import emblaCarouselSvelte from "embla-carousel-svelte"; | |
| import { setupTweenOpacity } from "./tweenOpacity"; | |
| import autoplay from "embla-carousel-autoplay"; | |
| import { Download, Image as ImageIcon } from "@gradio/icons"; | |
| import { normalise_file, type FileData } from "@gradio/client"; | |
| import { IconButton } from "@gradio/atoms"; | |
| import type { I18nFormatter } from "@gradio/utils"; | |
| type GalleryImage = { image: FileData; caption: string | null }; | |
| type GalleryData = GalleryImage[]; | |
| export let show_label = true; | |
| export let label: string; | |
| export let root = ""; | |
| export let proxy_url: null | string = null; | |
| export let value: GalleryData | null = null; | |
| export let preview: boolean; | |
| export let allow_preview = true; | |
| export let show_share_button = false; | |
| export let show_download_button = false; | |
| export let i18n: I18nFormatter; | |
| export let selected_index: number | null = null; | |
| export let interactive: boolean; | |
| const dispatch = createEventDispatcher<{ | |
| change: undefined; | |
| select: SelectData; | |
| }>(); | |
| let emblaApi; | |
| let options = { loop: true }; | |
| const onInit = (event) => { | |
| emblaApi = event.detail; | |
| console.log(emblaApi.slideNodes()); | |
| }; | |
| $: if (emblaApi != null) { | |
| const { applyTweenOpacity } = setupTweenOpacity(emblaApi); | |
| emblaApi | |
| .on("init", applyTweenOpacity) | |
| .on("scroll", applyTweenOpacity) | |
| .on("reInit", applyTweenOpacity); | |
| } | |
| // tracks whether the value of the gallery was reset | |
| let was_reset = true; | |
| $: was_reset = value == null || value.length === 0 ? true : was_reset; | |
| let resolved_value: GalleryData | null = null; | |
| $: resolved_value = | |
| value == null | |
| ? null | |
| : value.map((data) => ({ | |
| image: normalise_file(data.image, root, proxy_url) as FileData, | |
| caption: data.caption, | |
| })); | |
| let prev_value: GalleryData | null = value; | |
| if (selected_index == null && preview && value?.length) { | |
| selected_index = 0; | |
| } | |
| let old_selected_index: number | null = selected_index; | |
| $: if (!dequal(prev_value, value)) { | |
| // When value is falsy (clear button or first load), | |
| // preview determines the selected image | |
| if (was_reset) { | |
| selected_index = preview && value?.length ? 0 : null; | |
| was_reset = false; | |
| // Otherwise we keep the selected_index the same if the | |
| // gallery has at least as many elements as it did before | |
| } else { | |
| selected_index = | |
| selected_index != null && value != null && selected_index < value.length | |
| ? selected_index | |
| : null; | |
| } | |
| dispatch("change"); | |
| prev_value = value; | |
| } | |
| $: previous = | |
| ((selected_index ?? 0) + (resolved_value?.length ?? 0) - 1) % | |
| (resolved_value?.length ?? 0); | |
| $: next = ((selected_index ?? 0) + 1) % (resolved_value?.length ?? 0); | |
| function handle_preview_click(event: MouseEvent): void { | |
| const element = event.target as HTMLElement; | |
| const x = event.clientX; | |
| const width = element.offsetWidth; | |
| const centerX = width / 2; | |
| if (x < centerX) { | |
| selected_index = previous; | |
| } else { | |
| selected_index = next; | |
| } | |
| } | |
| function on_keydown(e: KeyboardEvent): void { | |
| switch (e.code) { | |
| case "Escape": | |
| e.preventDefault(); | |
| selected_index = null; | |
| break; | |
| case "ArrowLeft": | |
| e.preventDefault(); | |
| selected_index = previous; | |
| break; | |
| case "ArrowRight": | |
| e.preventDefault(); | |
| selected_index = next; | |
| break; | |
| default: | |
| break; | |
| } | |
| } | |
| $: { | |
| if (selected_index !== old_selected_index) { | |
| old_selected_index = selected_index; | |
| if (selected_index !== null) { | |
| dispatch("select", { | |
| index: selected_index, | |
| value: resolved_value?.[selected_index], | |
| }); | |
| } | |
| } | |
| } | |
| $: if (allow_preview) { | |
| scroll_to_img(selected_index); | |
| } | |
| let el: HTMLButtonElement[] = []; | |
| let container_element: HTMLDivElement; | |
| async function scroll_to_img(index: number | null): Promise<void> { | |
| if (typeof index !== "number") return; | |
| await tick(); | |
| if (el[index] === undefined) return; | |
| el[index]?.focus(); | |
| const { left: container_left, width: container_width } = | |
| container_element.getBoundingClientRect(); | |
| const { left, width } = el[index].getBoundingClientRect(); | |
| const relative_left = left - container_left; | |
| const pos = | |
| relative_left + | |
| width / 2 - | |
| container_width / 2 + | |
| container_element.scrollLeft; | |
| if (container_element && typeof container_element.scrollTo === "function") { | |
| container_element.scrollTo({ | |
| left: pos < 0 ? 0 : pos, | |
| behavior: "smooth", | |
| }); | |
| } | |
| } | |
| let client_height = 0; | |
| let window_height = 0; | |
| // Unlike `gr.Image()`, images specified via remote URLs are not cached in the server | |
| // and their remote URLs are directly passed to the client as `value[].image.url`. | |
| // The `download` attribute of the | |
| <void> { | |
| let response; | |
| try { | |
| response = await fetch_implementation(file_url); | |
| } catch (error) { | |
| if (error instanceof TypeError) { | |
| // If CORS is not allowed (https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#checking_that_the_fetch_was_successful), | |
| // open the link in a new tab instead, mimicing the behavior of the `download` attribute for remote URLs, | |
| // which is not ideal, but a reasonable fallback. | |
| window.open(file_url, "_blank", "noreferrer"); | |
| return; | |
| } | |
| throw error; | |
| } | |
| const blob = await response.blob(); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement("a"); | |
| link.href = url; | |
| link.download = name; | |
| link.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| $: selected_image = | |
| selected_index != null && resolved_value != null | |
| ? resolved_value[selected_index] | |
| : null; | |
| </script> | |
| <svelte:window bind:innerHeight={window_height} /> | |
| {#if show_label} | |
| <BlockLabel {show_label} Icon={ImageIcon} label={label || "Gallery"} /> | |
| {/if} | |
| {#if value == null || resolved_value == null || resolved_value.length === 0} | |
| <Empty unpadded_box={true} size="large"><ImageIcon /></Empty> | |
| {:else} | |
| {#if selected_image && allow_preview} | |
| <button on:keydown={on_keydown} class="preview"> | |
| <div class="icon-buttons"> | |
| {#if show_download_button} | |
| <div class="download-button-container"> | |
| <IconButton | |
| Icon={Download} | |
| label={i18n("common.download")} | |
| on:click={() => { | |
| const image = selected_image?.image; | |
| if (image == null) { | |
| return; | |
| } | |
| const { url, orig_name } = image; | |
| if (url) { | |
| download(url, orig_name ?? "image"); | |
| } | |
| }} | |
| /> | |
| </div> | |
| {/if} | |
| <ModifyUpload | |
| {i18n} | |
| absolute={false} | |
| on:clear={() => (selected_index = null)} | |
| /> | |
| </div> | |
| <button | |
| class="image-button" | |
| on:click={(event) => handle_preview_click(event)} | |
| style="height: calc(100% - {selected_image.caption ? '80px' : '60px'})" | |
| aria-label="detailed view of selected image" | |
| > | |
| <Image | |
| data-testid="detailed-image" | |
| src={selected_image.image.url} | |
| alt={selected_image.caption || ""} | |
| title={selected_image.caption || null} | |
| class={selected_image.caption && "with-caption"} | |
| loading="lazy" | |
| /> | |
| </button> | |
| {#if selected_image?.caption} | |
| <caption class="caption"> | |
| {selected_image.caption} | |
| </caption> | |
| {/if} | |
| <div | |
| bind:this={container_element} | |
| class="thumbnails scroll-hide" | |
| data-testid="container_el" | |
| > | |
| {#each resolved_value as image, i} | |
| <button | |
| bind:this={el[i]} | |
| on:click={() => (selected_index = i)} | |
| class="thumbnail-item thumbnail-small" | |
| class:selected={selected_index === i} | |
| aria-label={"Thumbnail " + (i + 1) + " of " + resolved_value.length} | |
| > | |
| <Image | |
| src={image.image.url} | |
| title={image.caption || null} | |
| data-testid={"thumbnail " + (i + 1)} | |
| alt="" | |
| loading="lazy" | |
| /> | |
| </button> | |
| {/each} | |
| </div> | |
| </button> | |
| {/if} | |
| <div class="embla"> | |
| <div | |
| class="embla__viewport" | |
| use:emblaCarouselSvelte={{ | |
| options, | |
| plugins: [ | |
| autoplay({ | |
| delay: 2000, | |
| }), | |
| ], | |
| }} | |
| on:emblaInit={onInit} | |
| > | |
| <div class="embla__container"> | |
| {#each resolved_value as entry, i} | |
| <div class="embla__slide"> | |
| {#if interactive} | |
| <div class="icon-button"> | |
| <ModifyUpload | |
| {i18n} | |
| absolute={false} | |
| on:clear={() => (value = null)} | |
| /> | |
| </div> | |
| {/if} | |
| <button | |
| class="embla__slide__img" | |
| class:selected={selected_index === i} | |
| on:click={() => (selected_index = i)} | |
| aria-label={"Thumbnail " + | |
| (i + 1) + | |
| " of " + | |
| resolved_value.length} | |
| > | |
| <img | |
| class="embla__slide__img" | |
| src={entry.image.url} | |
| alt={entry.caption || null} | |
| loading="lazy" | |
| /> | |
| {#if entry.caption} | |
| <div class="caption-label"> | |
| {entry.caption} | |
| </div> | |
| {/if} | |
| </button> | |
| </div> | |
| {/each} | |
| </div> | |
| </div> | |
| </div> | |
| {/if} | |
| <style lang="postcss"> | |
| .preview { | |
| display: flex; | |
| position: absolute; | |
| top: 0px; | |
| right: 0px; | |
| bottom: 0px; | |
| left: 0px; | |
| flex-direction: column; | |
| z-index: var(--layer-2); | |
| backdrop-filter: blur(8px); | |
| background: var(--background-fill-primary); | |
| height: var(--size-full); | |
| } | |
| .image-button { | |
| height: calc(100% - 60px); | |
| width: 100%; | |
| display: flex; | |
| } | |
| .image-button :global(img) { | |
| width: var(--size-full); | |
| height: var(--size-full); | |
| object-fit: contain; | |
| } | |
| .thumbnails :global(img) { | |
| object-fit: cover; | |
| width: var(--size-full); | |
| height: var(--size-full); | |
| } | |
| .preview :global(img.with-caption) { | |
| height: var(--size-full); | |
| } | |
| .caption { | |
| padding: var(--size-2) var(--size-3); | |
| overflow: hidden; | |
| color: var(--block-label-text-color); | |
| font-weight: var(--weight-semibold); | |
| text-align: center; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| align-self: center; | |
| } | |
| .thumbnails { | |
| display: flex; | |
| position: absolute; | |
| bottom: 0; | |
| justify-content: center; | |
| align-items: center; | |
| gap: var(--spacing-lg); | |
| width: var(--size-full); | |
| height: var(--size-14); | |
| overflow-x: scroll; | |
| } | |
| .thumbnail-item { | |
| --ring-color: transparent; | |
| position: relative; | |
| box-shadow: | |
| 0 0 0 2px var(--ring-color), | |
| var(--shadow-drop); | |
| border: 1px solid var(--border-color-primary); | |
| border-radius: var(--button-small-radius); | |
| background: var(--background-fill-secondary); | |
| aspect-ratio: var(--ratio-square); | |
| width: var(--size-full); | |
| height: var(--size-full); | |
| overflow: clip; | |
| } | |
| .thumbnail-item:hover { | |
| --ring-color: var(--color-accent); | |
| filter: brightness(1.1); | |
| } | |
| .thumbnail-item.selected { | |
| --ring-color: var(--color-accent); | |
| } | |
| .thumbnail-small { | |
| flex: none; | |
| transform: scale(0.9); | |
| transition: 0.075s; | |
| width: var(--size-9); | |
| height: var(--size-9); | |
| } | |
| .thumbnail-small.selected { | |
| --ring-color: var(--color-accent); | |
| transform: scale(1); | |
| border-color: var(--color-accent); | |
| } | |
| .caption-label { | |
| position: absolute; | |
| right: var(--block-label-margin); | |
| bottom: var(--block-label-margin); | |
| z-index: var(--layer-1); | |
| border-top: 1px solid var(--border-color-primary); | |
| border-left: 1px solid var(--border-color-primary); | |
| border-radius: var(--block-label-radius); | |
| background: var(--background-fill-secondary); | |
| padding: var(--block-label-padding); | |
| max-width: 80%; | |
| overflow: hidden; | |
| font-size: var(--block-label-text-size); | |
| text-align: left; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .icon-button { | |
| position: absolute; | |
| top: 0px; | |
| right: 0px; | |
| z-index: var(--layer-1); | |
| } | |
| .icon-buttons { | |
| display: flex; | |
| position: absolute; | |
| right: 0; | |
| } | |
| .icon-buttons .download-button-container { | |
| margin: var(--size-1) 0; | |
| } | |
| .embla { | |
| --slide-spacing: 1rem; | |
| --slide-size: 50%; | |
| --slide-height: 19rem; | |
| padding: 1.6rem; | |
| } | |
| .embla__viewport { | |
| overflow: hidden; | |
| } | |
| .embla__container { | |
| backface-visibility: hidden; | |
| display: flex; | |
| touch-action: pan-y; | |
| margin-left: calc(var(--slide-spacing) * -1); | |
| } | |
| .embla__slide { | |
| flex: 0 0 var(--slide-size); | |
| min-width: 0; | |
| padding-left: var(--slide-spacing); | |
| position: relative; | |
| } | |
| .embla__slide__img { | |
| display: block; | |
| height: var(--slide-height); | |
| width: 100%; | |
| object-fit: cover; | |
| } | |
| </style> | |