fix: gallery issues

This commit is contained in:
Calum H. (IMB11)
2026-08-29 13:56:00 +01:00
parent 1a560c64c7
commit b138716035
9 changed files with 118 additions and 168 deletions
@@ -43,7 +43,6 @@ const emit = defineEmits<{
cancel: []
close: []
edit: []
imageReady: []
next: []
previous: []
save: [payload: ImageViewerEditorSavePayload]
@@ -85,6 +84,7 @@ const {
fitToViewport,
setZoom,
setFit,
waitForRender,
exportPng,
handleKeyboardShortcut,
isTextEditing,
@@ -108,11 +108,31 @@ const viewZoom = computed(() => zoom.value / Math.max(fitScale.value, Number.EPS
const viewZoomPercent = computed(() => Math.round(viewZoom.value * 100))
const viewZoomed = computed(() => viewZoom.value > 1.001)
const canViewZoomIn = computed(() => canZoomIn.value && viewZoom.value < MAX_VIEW_ZOOM)
const nativeImageView = computed(
() =>
props.mode === 'view' &&
[props.item.src, props.item.editorSource?.path].some(isGifOrWebpSource),
)
let resizeObserver: ResizeObserver | undefined
let initializationGeneration = 0
let initializationChain = Promise.resolve()
let imageReadyFrame: number | undefined
function isGifOrWebpSource(source?: string) {
if (!source) return false
let pathname = source
try {
pathname = new URL(source, 'https://modrinth.invalid').pathname
} catch {
// ... ignore
}
try {
pathname = decodeURIComponent(pathname)
} catch {
// ... ignore
}
return /\.(?:gif|webp)$/i.test(pathname)
}
function queueInitialization() {
const generation = ++initializationGeneration
@@ -120,7 +140,6 @@ function queueInitialization() {
const editorDataPromise = props.loadData(props.item)
initializationChain = initializationChain.then(async () => {
if (generation !== initializationGeneration) return
let initialized = false
try {
const editorData = await editorDataPromise
if (generation !== initializationGeneration || !canvasElement.value) return
@@ -128,7 +147,8 @@ function queueInitialization() {
if (generation !== initializationGeneration) return
setInteractionEnabled(props.mode === 'edit')
observeViewport()
initialized = true
await waitForRender()
if (generation !== initializationGeneration) return
} catch (error) {
if (generation !== initializationGeneration) return
handleError(error)
@@ -136,20 +156,11 @@ function queueInitialization() {
} finally {
if (generation === initializationGeneration) {
loadingEditorData.value = false
if (initialized) notifyImageReady()
}
}
})
}
function notifyImageReady() {
if (imageReadyFrame !== undefined) cancelAnimationFrame(imageReadyFrame)
imageReadyFrame = requestAnimationFrame(() => {
imageReadyFrame = undefined
emit('imageReady')
})
}
function observeViewport() {
resizeObserver?.disconnect()
if (!viewport.value || !fitBounds.value) return
@@ -164,7 +175,6 @@ function fitEditorToViewport() {
if (!viewportSize) return
fitToViewport(viewportSize.width, viewportSize.height)
if (isFit.value) centerViewport()
else notifyImageReady()
}
function getViewportSize() {
@@ -186,72 +196,9 @@ function centerViewport() {
0,
(viewport.value.scrollHeight - viewport.value.clientHeight) / 2,
)
notifyImageReady()
})
}
function getTextContrast(target: HTMLElement): 'dark' | 'light' {
const renderedCanvas = viewport.value?.querySelector<HTMLCanvasElement>('canvas.lower-canvas')
const context = renderedCanvas?.getContext('2d', { willReadFrequently: true })
if (!renderedCanvas || !context) return 'light'
const targetBounds = target.getBoundingClientRect()
const canvasBounds = renderedCanvas.getBoundingClientRect()
const intersection = {
left: Math.max(targetBounds.left, canvasBounds.left),
top: Math.max(targetBounds.top, canvasBounds.top),
right: Math.min(targetBounds.right, canvasBounds.right),
bottom: Math.min(targetBounds.bottom, canvasBounds.bottom),
}
if (intersection.right <= intersection.left || intersection.bottom <= intersection.top)
return 'light'
const scaleX = renderedCanvas.width / canvasBounds.width
const scaleY = renderedCanvas.height / canvasBounds.height
const sourceX = Math.max(0, Math.floor((intersection.left - canvasBounds.left) * scaleX))
const sourceY = Math.max(0, Math.floor((intersection.top - canvasBounds.top) * scaleY))
const sourceWidth = Math.min(
renderedCanvas.width - sourceX,
Math.max(1, Math.ceil((intersection.right - intersection.left) * scaleX)),
)
const sourceHeight = Math.min(
renderedCanvas.height - sourceY,
Math.max(1, Math.ceil((intersection.bottom - intersection.top) * scaleY)),
)
try {
const pixels = context.getImageData(sourceX, sourceY, sourceWidth, sourceHeight).data
const sampleStride = Math.max(1, Math.floor(Math.sqrt((sourceWidth * sourceHeight) / 4096)))
let luminanceTotal = 0
let sampleCount = 0
for (let y = 0; y < sourceHeight; y += sampleStride) {
for (let x = 0; x < sourceWidth; x += sampleStride) {
const offset = (y * sourceWidth + x) * 4
const red = srgbToLinear(pixels[offset] / 255)
const green = srgbToLinear(pixels[offset + 1] / 255)
const blue = srgbToLinear(pixels[offset + 2] / 255)
const alpha = pixels[offset + 3] / 255
luminanceTotal += (0.2126 * red + 0.7152 * green + 0.0722 * blue) * alpha
sampleCount++
}
}
const targetArea = targetBounds.width * targetBounds.height
const intersectionArea =
(intersection.right - intersection.left) * (intersection.bottom - intersection.top)
const coverage = targetArea > 0 ? intersectionArea / targetArea : 0
if (coverage < 0.9) return 'light'
const averageLuminance = sampleCount > 0 ? luminanceTotal / sampleCount : 0
return averageLuminance > 0.179 ? 'dark' : 'light'
} catch {
return 'light'
}
}
function srgbToLinear(value: number) {
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
}
function resetView() {
setFit()
centerViewport()
@@ -360,10 +307,7 @@ function stopPan() {
const pan = panning.value
panning.value = undefined
if (!pan || props.mode !== 'view') return
if (pan.moved) {
notifyImageReady()
return
}
if (pan.moved) return
if (viewZoomed.value) resetView()
else setViewZoom(CLICK_ZOOM)
}
@@ -427,7 +371,6 @@ onMounted(async () => {
onBeforeUnmount(() => {
initializationGeneration++
if (imageReadyFrame !== undefined) cancelAnimationFrame(imageReadyFrame)
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('keyup', handleKeyup)
document.removeEventListener('edit-menu:undo', handleEditMenuUndo)
@@ -436,7 +379,7 @@ onBeforeUnmount(() => {
void dispose()
})
defineExpose({ getTextContrast, markSaved })
defineExpose({ markSaved })
</script>
<template>
@@ -456,20 +399,31 @@ defineExpose({ getTextContrast, markSaved })
@wheel="handleWheel"
>
<img
v-if="mode === 'view' && loadingEditorData"
v-if="mode === 'view' && loadingEditorData && !nativeImageView"
:src="item.src"
:alt="item.alt"
class="pointer-events-none relative z-[2] m-auto block max-h-full max-w-full shrink-0 object-contain"
class="pointer-events-none relative z-[2] m-auto block h-full w-full shrink-0 object-contain"
draggable="false"
/>
<div
v-show="!loadingEditorData"
v-show="nativeImageView || !loadingEditorData"
class="editor-canvas relative z-[2] m-auto shrink-0"
:class="{
'h-full w-full': nativeImageView && loadingEditorData,
'is-native-image-view': nativeImageView,
}"
@pointerenter="updateBrushPointer"
@pointermove="updateBrushPointer"
@pointerleave="brushPointer.visible = false"
>
<canvas ref="canvasElement" :aria-label="item.alt" role="img" />
<img
v-if="nativeImageView"
:src="item.src"
:alt="item.alt"
class="pointer-events-none absolute inset-0 z-[2] h-full w-full object-contain"
draggable="false"
/>
<div
v-if="hasBrushPointer && brushPointer.visible"
class="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-solid shadow-[0_0_0_1px_rgb(0_0_0_/_80%),inset_0_0_0_1px_rgb(255_255_255_/_35%)]"
@@ -582,6 +536,22 @@ defineExpose({ getTextContrast, markSaved })
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 24%);
}
.editor-canvas.is-native-image-view :deep(.canvas-container) {
visibility: hidden;
}
.editor-viewport.is-view .editor-canvas.is-native-image-view {
cursor: zoom-in;
}
.editor-viewport.is-view-zoomed .editor-canvas.is-native-image-view {
cursor: grab;
}
.editor-viewport.is-panning .editor-canvas.is-native-image-view {
cursor: grabbing;
}
.editor-viewport.is-view :deep(.upper-canvas) {
cursor: zoom-in !important;
}
@@ -34,16 +34,11 @@ const emit = defineEmits<{
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)
@@ -129,11 +124,8 @@ function preloadItemsAround(index: number) {
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
@@ -165,7 +157,6 @@ function finishEditing() {
function hide() {
if (activeId.value === null || props.saving) return
cancelHeadingContrastUpdate()
activeId.value = null
mode.value = 'view'
closeAfterEditing.value = false
@@ -177,33 +168,12 @@ function 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')
}
@@ -237,7 +207,6 @@ function handleKeydown(event: KeyboardEvent) {
onMounted(() => document.addEventListener('keydown', handleKeydown))
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeydown)
cancelHeadingContrastUpdate()
itemDataCache.clear()
itemImageCache.clear()
if (activeId.value !== null) context?.onHide?.()
@@ -261,20 +230,16 @@ defineExpose({ show, edit, hide, next, previous, markSavedAndView })
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">
<div class="viewer-heading-text 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'"
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"
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'"
class="mb-0 mt-1 max-w-[min(42rem,70vw)] truncate text-xs leading-snug text-white"
>
{{ activeItem.description }}
</p>
@@ -296,7 +261,6 @@ defineExpose({ show, edit, hide, next, previous, markSavedAndView })
@previous="previous"
@cancel="finishEditing"
@save="emit('save', $event)"
@image-ready="updateHeadingContrast"
>
<template #actions>
<slot name="actions" :item="activeItem" :index="activeIndex" :hide="hide" />
@@ -305,3 +269,9 @@ defineExpose({ show, edit, hide, next, previous, markSavedAndView })
</div>
</Teleport>
</template>
<style scoped>
.viewer-heading-text {
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 90%));
}
</style>
@@ -1353,6 +1353,16 @@ export function useImageEditor() {
applyDisplayScale()
}
function waitForRender() {
const editorCanvas = canvas.value
if (!editorCanvas) return Promise.resolve()
return new Promise<void>((resolve) => {
editorCanvas.once('after:render', () => resolve())
editorCanvas.requestRenderAll()
})
}
function applyDisplayScale() {
const editorCanvas = canvas.value
if (!editorCanvas) return
@@ -1621,6 +1631,7 @@ export function useImageEditor() {
fitToViewport,
setZoom,
setFit,
waitForRender,
exportPng,
handleKeyboardShortcut,
isTextEditing,