|
<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 { Video } from "@gradio/video/shared"; |
|
import { dequal } from "dequal"; |
|
import { createEventDispatcher, onMount } from "svelte"; |
|
import { tick } from "svelte"; |
|
import type { GalleryImage, GalleryVideo } from "../types"; |
|
|
|
import { |
|
Download, |
|
Image as ImageIcon, |
|
Maximize, |
|
Minimize, |
|
Clear, |
|
Play |
|
} from "@gradio/icons"; |
|
import { FileData } from "@gradio/client"; |
|
import { format_gallery_for_sharing } from "./utils"; |
|
import { IconButton, IconButtonWrapper } from "@gradio/atoms"; |
|
import type { I18nFormatter } from "@gradio/utils"; |
|
|
|
type GalleryData = GalleryImage | GalleryVideo; |
|
|
|
export let show_label = true; |
|
export let label: string; |
|
export let value: GalleryData[] | null = null; |
|
export let columns: number | number[] | undefined = [2]; |
|
export let rows: number | number[] | undefined = undefined; |
|
export let height: number | "auto" = "auto"; |
|
export let preview: boolean; |
|
export let allow_preview = true; |
|
export let object_fit: "contain" | "cover" | "fill" | "none" | "scale-down" = |
|
"cover"; |
|
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; |
|
export let _fetch: typeof fetch; |
|
export let mode: "normal" | "minimal" = "normal"; |
|
export let show_fullscreen_button = true; |
|
|
|
let is_full_screen = false; |
|
let gallery_container: HTMLElement; |
|
|
|
const dispatch = createEventDispatcher<{ |
|
change: undefined; |
|
select: SelectData; |
|
}>(); |
|
|
|
|
|
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) => { |
|
if ("video" in data) { |
|
return { |
|
video: data.video as FileData, |
|
caption: data.caption |
|
}; |
|
} else if ("image" in data) { |
|
return { image: data.image as FileData, caption: data.caption }; |
|
} |
|
return {}; |
|
}) as GalleryData[]); |
|
|
|
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.offsetX; |
|
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 window_height = 0; |
|
|
|
|
|
|
|
<a> tag doesn't work for remote URLs (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#download), |
|
// so we need to download the image via JS as below. |
|
async function download(file_url: string, name: string): Promise<void> { |
|
let response; |
|
try { |
|
response = await _fetch(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_media = |
|
selected_index != null && resolved_value != null |
|
? resolved_value[selected_index] |
|
: null; |
|
|
|
onMount(() => { |
|
document.addEventListener("fullscreenchange", () => { |
|
is_full_screen = !!document.fullscreenElement; |
|
}); |
|
}); |
|
|
|
const toggle_full_screen = async (): Promise<void> => { |
|
if (!is_full_screen) { |
|
await gallery_container.requestFullscreen(); |
|
} else { |
|
await document.exitFullscreen(); |
|
} |
|
}; |
|
</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} |
|
<div class="gallery-container" bind:this={gallery_container}> |
|
{#if selected_media && allow_preview} |
|
<button |
|
on:keydown={on_keydown} |
|
class="preview" |
|
class:minimal={mode === "minimal"} |
|
> |
|
<IconButtonWrapper> |
|
{#if show_download_button} |
|
<IconButton |
|
Icon={Download} |
|
label={i18n("common.download")} |
|
on:click={() => { |
|
const image = |
|
"image" in selected_media |
|
? selected_media?.image |
|
: selected_media?.video; |
|
if (image == null) { |
|
return; |
|
} |
|
const { url, orig_name } = image; |
|
if (url) { |
|
download(url, orig_name ?? "image"); |
|
} |
|
}} |
|
/> |
|
{/if} |
|
|
|
{#if show_fullscreen_button && !is_full_screen} |
|
<IconButton |
|
Icon={is_full_screen ? Minimize : Maximize} |
|
label={is_full_screen |
|
? "Exit full screen" |
|
: "View in full screen"} |
|
on:click={toggle_full_screen} |
|
/> |
|
{/if} |
|
|
|
{#if show_fullscreen_button && is_full_screen} |
|
<IconButton |
|
Icon={Minimize} |
|
label="Exit full screen" |
|
on:click={toggle_full_screen} |
|
/> |
|
{/if} |
|
|
|
{#if !is_full_screen} |
|
<IconButton |
|
Icon={Clear} |
|
label="Close" |
|
on:click={() => (selected_index = null)} |
|
/> |
|
{/if} |
|
</IconButtonWrapper> |
|
<button |
|
class="media-button" |
|
on:click={"image" in selected_media |
|
? (event) => handle_preview_click(event) |
|
: null} |
|
style="height: calc(100% - {selected_media.caption |
|
? '80px' |
|
: '60px'})" |
|
aria-label="detailed view of selected image" |
|
> |
|
{#if "image" in selected_media} |
|
<Image |
|
data-testid="detailed-image" |
|
src={selected_media.image.url} |
|
alt={selected_media.caption || ""} |
|
title={selected_media.caption || null} |
|
class={selected_media.caption && "with-caption"} |
|
loading="lazy" |
|
/> |
|
{:else} |
|
<Video |
|
src={selected_media.video.url} |
|
data-testid={"detailed-video"} |
|
alt={selected_media.caption || ""} |
|
loading="lazy" |
|
loop={false} |
|
is_stream={false} |
|
muted={false} |
|
controls={true} |
|
/> |
|
{/if} |
|
</button> |
|
{#if selected_media?.caption} |
|
<caption class="caption"> |
|
{selected_media.caption} |
|
</caption> |
|
{/if} |
|
<div |
|
bind:this={container_element} |
|
class="thumbnails scroll-hide" |
|
data-testid="container_el" |
|
> |
|
{#each resolved_value as media, i} |
|
<button |
|
bind:this={el[i]} |
|
on:click={() => (selected_index = i)} |
|
class="thumbnail-item thumbnail-small" |
|
class:selected={selected_index === i && mode !== "minimal"} |
|
aria-label={"Thumbnail " + |
|
(i + 1) + |
|
" of " + |
|
resolved_value.length} |
|
> |
|
{#if "image" in media} |
|
<Image |
|
src={media.image.url} |
|
title={media.caption || null} |
|
data-testid={"thumbnail " + (i + 1)} |
|
alt="" |
|
loading="lazy" |
|
/> |
|
{:else} |
|
<Play /> |
|
<Video |
|
src={media.video.url} |
|
title={media.caption || null} |
|
is_stream={false} |
|
data-testid={"thumbnail " + (i + 1)} |
|
alt="" |
|
loading="lazy" |
|
loop={false} |
|
/> |
|
{/if} |
|
</button> |
|
{/each} |
|
</div> |
|
</button> |
|
{/if} |
|
|
|
<div |
|
class="grid-wrap" |
|
class:minimal={mode === "minimal"} |
|
class:fixed-height={mode !== "minimal" && (!height || height == "auto")} |
|
class:hidden={is_full_screen} |
|
> |
|
<div |
|
class="grid-container" |
|
style="--grid-cols:{columns}; --grid-rows:{rows}; --object-fit: {object_fit}; height: {height};" |
|
class:pt-6={show_label} |
|
> |
|
{#if interactive} |
|
<div class="icon-button"> |
|
<ModifyUpload {i18n} on:clear={() => (value = [])} /> |
|
</div> |
|
{/if} |
|
<IconButtonWrapper> |
|
{#if show_share_button} |
|
<div class="icon-button"> |
|
<ShareButton |
|
{i18n} |
|
on:share |
|
on:error |
|
value={resolved_value} |
|
formatter={format_gallery_for_sharing} |
|
/> |
|
</div> |
|
{/if} |
|
</IconButtonWrapper> |
|
{#each resolved_value as entry, i} |
|
<button |
|
class="thumbnail-item thumbnail-lg" |
|
class:selected={selected_index === i} |
|
on:click={() => (selected_index = i)} |
|
aria-label={"Thumbnail " + (i + 1) + " of " + resolved_value.length} |
|
> |
|
{#if "image" in entry} |
|
<Image |
|
alt={entry.caption || ""} |
|
src={typeof entry.image === "string" |
|
? entry.image |
|
: entry.image.url} |
|
loading="lazy" |
|
/> |
|
{:else} |
|
<Play /> |
|
<Video |
|
src={entry.video.url} |
|
title={entry.caption || null} |
|
is_stream={false} |
|
data-testid={"thumbnail " + (i + 1)} |
|
alt="" |
|
loading="lazy" |
|
loop={false} |
|
/> |
|
{/if} |
|
{#if entry.caption} |
|
<div class="caption-label"> |
|
{entry.caption} |
|
</div> |
|
{/if} |
|
</button> |
|
{/each} |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<style lang="postcss"> |
|
.image-container { |
|
height: 100%; |
|
position: relative; |
|
} |
|
.image-container :global(img), |
|
button { |
|
width: var(--size-full); |
|
height: var(--size-full); |
|
object-fit: contain; |
|
display: block; |
|
border-radius: var(--radius-lg); |
|
} |
|
|
|
.preview { |
|
display: flex; |
|
position: absolute; |
|
flex-direction: column; |
|
z-index: var(--layer-2); |
|
border-radius: calc(var(--block-radius) - var(--block-border-width)); |
|
-webkit-backdrop-filter: blur(8px); |
|
backdrop-filter: blur(8px); |
|
width: var(--size-full); |
|
height: var(--size-full); |
|
} |
|
|
|
.preview.minimal { |
|
width: fit-content; |
|
height: fit-content; |
|
} |
|
|
|
.preview::before { |
|
content: ""; |
|
position: absolute; |
|
z-index: var(--layer-below); |
|
background: var(--background-fill-primary); |
|
opacity: 0.9; |
|
width: var(--size-full); |
|
height: var(--size-full); |
|
} |
|
|
|
.fixed-height { |
|
min-height: var(--size-80); |
|
max-height: 55vh; |
|
} |
|
|
|
@media (--screen-xl) { |
|
.fixed-height { |
|
min-height: 450px; |
|
} |
|
} |
|
|
|
.media-button { |
|
height: calc(100% - 60px); |
|
width: 100%; |
|
display: flex; |
|
} |
|
.media-button :global(img), |
|
.media-button :global(video) { |
|
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); |
|
} |
|
.thumbnails :global(svg) { |
|
position: absolute; |
|
top: var(--size-2); |
|
left: var(--size-2); |
|
width: 50%; |
|
height: 50%; |
|
opacity: 50%; |
|
} |
|
.preview :global(img.with-caption) { |
|
height: var(--size-full); |
|
} |
|
|
|
.preview.minimal :global(img.with-caption) { |
|
height: auto; |
|
} |
|
|
|
.selectable { |
|
cursor: crosshair; |
|
} |
|
|
|
.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: |
|
inset 0 0 0 1px 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); |
|
border-color: var(--color-accent); |
|
filter: brightness(1.1); |
|
} |
|
|
|
.thumbnail-item.selected { |
|
--ring-color: var(--color-accent); |
|
border-color: var(--color-accent); |
|
} |
|
|
|
.thumbnail-item :global(svg) { |
|
position: absolute; |
|
top: 50%; |
|
left: 50%; |
|
width: 50%; |
|
height: 50%; |
|
opacity: 50%; |
|
transform: translate(-50%, -50%); |
|
} |
|
|
|
.thumbnail-item :global(video) { |
|
width: var(--size-full); |
|
height: var(--size-full); |
|
overflow: hidden; |
|
object-fit: cover; |
|
} |
|
|
|
.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); |
|
} |
|
|
|
.thumbnail-small > img { |
|
width: var(--size-full); |
|
height: var(--size-full); |
|
overflow: hidden; |
|
object-fit: var(--object-fit); |
|
} |
|
|
|
.grid-wrap { |
|
position: relative; |
|
padding: var(--size-2); |
|
height: var(--size-full); |
|
overflow-y: scroll; |
|
} |
|
|
|
.grid-container { |
|
display: grid; |
|
position: relative; |
|
grid-template-rows: repeat(var(--grid-rows), minmax(100px, 1fr)); |
|
grid-template-columns: repeat(var(--grid-cols), minmax(100px, 1fr)); |
|
grid-auto-rows: minmax(100px, 1fr); |
|
gap: var(--spacing-lg); |
|
} |
|
|
|
.thumbnail-lg > :global(img) { |
|
width: var(--size-full); |
|
height: var(--size-full); |
|
overflow: hidden; |
|
object-fit: var(--object-fit); |
|
} |
|
|
|
.thumbnail-lg:hover .caption-label { |
|
opacity: 0.5; |
|
} |
|
|
|
.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 { |
|
top: 1px; |
|
right: 1px; |
|
} |
|
|
|
.grid-wrap.minimal { |
|
padding: 0; |
|
} |
|
</style> |
|
|