Files
modrinth/packages/ui/src/components/image-viewer-editor/index.vue
T
2026-08-29 13:56:00 +01:00

278 lines
7.2 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { injectImageViewerEditor } from '#ui/providers/image-viewer-editor'
import Editor from './editor.vue'
import type { ImageViewerEditorMode } from './image-viewer-editor-types'
import type {
ImageViewerEditorData,
ImageViewerEditorItem,
ImageViewerEditorSavePayload,
} from './types'
const MAX_CACHED_ITEMS = 5
const props = withDefaults(
defineProps<{
items: ImageViewerEditorItem[]
editor?: 'enabled' | 'disabled'
saving?: boolean
}>(),
{
editor: 'disabled',
saving: false,
},
)
const emit = defineEmits<{
show: [item: ImageViewerEditorItem, index: number]
hide: []
navigate: [item: ImageViewerEditorItem, index: number, direction: 'next' | 'previous']
save: [payload: ImageViewerEditorSavePayload]
}>()
const activeId = ref<string | null>(null)
const mode = ref<ImageViewerEditorMode>('view')
const closeAfterEditing = ref(false)
const editorComponent = ref<InstanceType<typeof Editor>>()
const context = injectImageViewerEditor(null)
const itemDataCache = new Map<string, Promise<ImageViewerEditorData>>()
const itemImageCache = new Map<string, HTMLImageElement>()
const activeIndex = computed(() => props.items.findIndex((item) => item.id === activeId.value))
const activeItem = computed(() => props.items[activeIndex.value] ?? null)
const canEdit = computed(
() =>
props.editor === 'enabled' &&
Boolean(context?.loadEditorData) &&
Boolean(activeItem.value?.editorSource),
)
watch(activeItem, (item) => {
if (!item && activeId.value !== null) hide()
})
function getItemCacheKey(item: ImageViewerEditorItem) {
return [item.id, item.src, item.editorSource?.id, item.editorSource?.path].join('\0')
}
function loadItemData(item: ImageViewerEditorItem): Promise<ImageViewerEditorData> {
const key = getItemCacheKey(item)
const cached = itemDataCache.get(key)
if (cached) {
itemDataCache.delete(key)
itemDataCache.set(key, cached)
return cached
}
const promise = (async () => {
if (item.editorSource && context) return await context.loadEditorData(item.editorSource)
const response = await fetch(item.src)
if (!response.ok) throw new Error(`Could not load image: ${response.statusText}`)
return { source: await response.blob() }
})()
itemDataCache.set(key, promise)
while (itemDataCache.size > MAX_CACHED_ITEMS) {
const oldestKey = itemDataCache.keys().next().value
if (oldestKey === undefined) break
itemDataCache.delete(oldestKey)
}
void promise.catch(() => {
if (itemDataCache.get(key) === promise) itemDataCache.delete(key)
})
return promise
}
function preloadItemImage(item: ImageViewerEditorItem) {
const key = getItemCacheKey(item)
const cached = itemImageCache.get(key)
if (cached) {
itemImageCache.delete(key)
itemImageCache.set(key, cached)
return
}
const image = new Image()
image.src = item.src
itemImageCache.set(key, image)
while (itemImageCache.size > MAX_CACHED_ITEMS) {
const oldestKey = itemImageCache.keys().next().value
if (oldestKey === undefined) break
itemImageCache.delete(oldestKey)
}
void image.decode().catch(() => undefined)
}
function preloadItemsAround(index: number) {
if (!props.items.length) return
const indexes = new Set([
index,
(index - 1 + props.items.length) % props.items.length,
(index + 1) % props.items.length,
])
for (const itemIndex of indexes) {
const item = props.items[itemIndex]
if (item) {
preloadItemImage(item)
void loadItemData(item).catch(() => undefined)
}
}
}
function show(index: number) {
const item = props.items[index]
if (!item) return
preloadItemsAround(index)
if (activeId.value === null) {
context?.onShow?.()
}
activeId.value = item.id
mode.value = 'view'
closeAfterEditing.value = false
emit('show', item, index)
}
async function edit(index: number) {
show(index)
await beginEditing(true)
}
async function beginEditing(closeOnCancel = false) {
if (!canEdit.value || props.saving) return
closeAfterEditing.value = closeOnCancel
mode.value = 'edit'
await nextTick()
}
function finishEditing() {
if (props.saving) return
if (closeAfterEditing.value) {
hide()
} else {
mode.value = 'view'
}
}
function hide() {
if (activeId.value === null || props.saving) return
activeId.value = null
mode.value = 'view'
closeAfterEditing.value = false
itemDataCache.clear()
itemImageCache.clear()
context?.onHide?.()
emit('hide')
}
function navigate(offset: number, direction: 'next' | 'previous') {
if (mode.value !== 'view' || props.items.length < 2) return
const index = (activeIndex.value + offset + props.items.length) % props.items.length
preloadItemsAround(index)
activeId.value = props.items[index].id
emit('navigate', props.items[index], index, direction)
}
function next() {
navigate(1, 'next')
}
function previous() {
navigate(-1, 'previous')
}
async function markSavedAndView(itemId?: string) {
editorComponent.value?.markSaved()
mode.value = 'view'
closeAfterEditing.value = false
await nextTick()
if (itemId && props.items.some((item) => item.id === itemId)) activeId.value = itemId
}
function handleKeydown(event: KeyboardEvent) {
if (!activeItem.value || mode.value === 'edit' || document.querySelector('.modal-root')) return
if (event.key === 'Escape') {
event.preventDefault()
hide()
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
previous()
} else if (event.key === 'ArrowRight') {
event.preventDefault()
next()
}
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeydown)
itemDataCache.clear()
itemImageCache.clear()
if (activeId.value !== null) context?.onHide?.()
})
defineExpose({ show, edit, hide, next, previous, markSavedAndView })
</script>
<template>
<Teleport to="body">
<div
v-if="activeItem"
class="fixed inset-0 z-[110] overflow-hidden bg-black/95 text-white"
role="dialog"
aria-modal="true"
:aria-label="activeItem.title || activeItem.alt"
@click.self="mode === 'view' && hide()"
>
<header
v-if="activeItem.title || activeItem.description"
class="absolute inset-x-6 top-[calc(var(--top-bar-height,3rem)_+_1.5rem)] z-10 min-w-0"
@click.stop
>
<div class="viewer-heading-text w-fit min-w-0 max-w-full">
<h2
v-if="activeItem.title"
class="m-0 max-w-[min(42rem,70vw)] truncate text-base font-semibold leading-snug text-contrast"
>
{{ activeItem.title }}
</h2>
<p
v-if="activeItem.description"
class="mb-0 mt-1 max-w-[min(42rem,70vw)] truncate text-xs leading-snug text-white"
>
{{ activeItem.description }}
</p>
</div>
</header>
<Editor
ref="editorComponent"
:item="activeItem"
:mode="mode"
:index="activeIndex"
:count="items.length"
:can-edit="canEdit"
:saving="saving"
:load-data="loadItemData"
@close="hide"
@edit="beginEditing"
@next="next"
@previous="previous"
@cancel="finishEditing"
@save="emit('save', $event)"
>
<template #actions>
<slot name="actions" :item="activeItem" :index="activeIndex" :hide="hide" />
</template>
</Editor>
</div>
</Teleport>
</template>
<style scoped>
.viewer-heading-text {
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 90%));
}
</style>