Spaces:
Sleeping
Sleeping
<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> | |