mirror of
https://github.com/modrinth/code.git
synced 2026-09-03 05:25:58 +00:00
feat(instance-sync): screenshots sync (#7218)
* pnpm prepr * feat(instance-sync): screenshots sync * fix: prepr + fmt * fix: qa * feat: screenshit editor * feat: screenshot* editor qa * fix: qa * fix: lint * fix: aecsocket rev * qa: a11y tab navigation focus is cut off * qa: Change “Edited” badge to be bg-highlight-green * qa: friends list input style wrong * qa: toolbar width + labels * qa: multiselect changes * qa: anim changes * qa: card hover colors * qa: copy feedback * qa: hide date * qa: instance icon in overflow/contextmenu * qa: spacing * qa: group empty state text * qa: drag preview badge * qa: group deletion skip warning if no screenshots * qa: redesign editor * qa: remove screenshot parent/edited badge stuff * qa: control font size consistency * qa: viewer copy control * qa: editor fixes * qa: redirect on disable screenshot sync * qa: margin police * fix: crop * fix: qa * feat: initial start on basic instance file syncing (#7220) * qa: final * qa: final 2 * fix: lint + prepr * fix: copy * fix: screenshot editing outside of app causing ghost files in db + sync fail rollback impl * chore: split up * fix: fmt --------- Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
<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 titleContrast = ref<'dark' | 'light'>('light')
|
||||
const descriptionContrast = ref<'dark' | 'light'>('light')
|
||||
const closeAfterEditing = ref(false)
|
||||
const editorComponent = ref<InstanceType<typeof Editor>>()
|
||||
const titleElement = ref<HTMLElement>()
|
||||
const descriptionElement = ref<HTMLElement>()
|
||||
const context = injectImageViewerEditor(null)
|
||||
const itemDataCache = new Map<string, Promise<ImageViewerEditorData>>()
|
||||
const itemImageCache = new Map<string, HTMLImageElement>()
|
||||
let headingContrastTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
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
|
||||
cancelHeadingContrastUpdate()
|
||||
preloadItemsAround(index)
|
||||
if (activeId.value === null) {
|
||||
titleContrast.value = 'light'
|
||||
descriptionContrast.value = 'light'
|
||||
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
|
||||
cancelHeadingContrastUpdate()
|
||||
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
|
||||
cancelHeadingContrastUpdate()
|
||||
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 updateHeadingContrast() {
|
||||
cancelHeadingContrastUpdate()
|
||||
headingContrastTimer = setTimeout(() => {
|
||||
headingContrastTimer = undefined
|
||||
if (titleElement.value) {
|
||||
titleContrast.value = editorComponent.value?.getTextContrast(titleElement.value) ?? 'light'
|
||||
}
|
||||
if (descriptionElement.value) {
|
||||
descriptionContrast.value =
|
||||
editorComponent.value?.getTextContrast(descriptionElement.value) ?? 'light'
|
||||
}
|
||||
}, 120)
|
||||
}
|
||||
|
||||
function cancelHeadingContrastUpdate() {
|
||||
if (headingContrastTimer === undefined) return
|
||||
clearTimeout(headingContrastTimer)
|
||||
headingContrastTimer = undefined
|
||||
}
|
||||
|
||||
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)
|
||||
cancelHeadingContrastUpdate()
|
||||
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="w-fit min-w-0 max-w-full">
|
||||
<h2
|
||||
v-if="activeItem.title"
|
||||
ref="titleElement"
|
||||
class="m-0 max-w-[min(42rem,70vw)] truncate text-base font-semibold leading-snug transition-colors duration-200 ease-out"
|
||||
:class="titleContrast === 'dark' ? 'text-gray-950' : 'text-white'"
|
||||
>
|
||||
{{ activeItem.title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="activeItem.description"
|
||||
ref="descriptionElement"
|
||||
class="mb-0 mt-1 max-w-[min(42rem,70vw)] truncate text-xs leading-snug opacity-70 transition-colors duration-200 ease-out"
|
||||
:class="descriptionContrast === 'dark' ? 'text-gray-950' : '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)"
|
||||
@image-ready="updateHeadingContrast"
|
||||
>
|
||||
<template #actions>
|
||||
<slot name="actions" :item="activeItem" :index="activeIndex" :hide="hide" />
|
||||
</template>
|
||||
</Editor>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
Reference in New Issue
Block a user