diff --git a/apps/app-frontend/src/components/ui/screenshots-page/card.vue b/apps/app-frontend/src/components/ui/screenshots-page/card.vue
index adf1222ef8..f1de4c6532 100644
--- a/apps/app-frontend/src/components/ui/screenshots-page/card.vue
+++ b/apps/app-frontend/src/components/ui/screenshots-page/card.vue
@@ -3,11 +3,16 @@
@@ -91,7 +166,7 @@ watch(
ref="card"
role="button"
tabindex="0"
- class="group relative aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
+ class="group relative isolate aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
:class="{
'!border-contrast brightness-110': selected,
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
@@ -115,7 +190,7 @@ watch(
>
-
@@ -193,3 +274,9 @@ watch(
+
+
diff --git a/apps/app-frontend/src/components/ui/screenshots-page/group.vue b/apps/app-frontend/src/components/ui/screenshots-page/group.vue
index 2a5c1c2997..358126f221 100644
--- a/apps/app-frontend/src/components/ui/screenshots-page/group.vue
+++ b/apps/app-frontend/src/components/ui/screenshots-page/group.vue
@@ -1,7 +1,7 @@
diff --git a/packages/ui/src/components/image-viewer-editor/editor.vue b/packages/ui/src/components/image-viewer-editor/editor.vue
index 19e793b016..0d1fed9f37 100644
--- a/packages/ui/src/components/image-viewer-editor/editor.vue
+++ b/packages/ui/src/components/image-viewer-editor/editor.vue
@@ -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
('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 })
@@ -456,20 +399,31 @@ defineExpose({ getTextContrast, markSaved })
@wheel="handleWheel"
>
+
(null)
const mode = ref
('view')
-const titleContrast = ref<'dark' | 'light'>('light')
-const descriptionContrast = ref<'dark' | 'light'>('light')
const closeAfterEditing = ref(false)
const editorComponent = ref>()
-const titleElement = ref()
-const descriptionElement = ref()
const context = injectImageViewerEditor(null)
const itemDataCache = new Map>()
const itemImageCache = new Map()
-let headingContrastTimer: ReturnType | 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
>
-
+
{{ activeItem.title }}
{{ activeItem.description }}
@@ -296,7 +261,6 @@ defineExpose({ show, edit, hide, next, previous, markSavedAndView })
@previous="previous"
@cancel="finishEditing"
@save="emit('save', $event)"
- @image-ready="updateHeadingContrast"
>
@@ -305,3 +269,9 @@ defineExpose({ show, edit, hide, next, previous, markSavedAndView })
+
+
diff --git a/packages/ui/src/components/image-viewer-editor/use-image-editor.ts b/packages/ui/src/components/image-viewer-editor/use-image-editor.ts
index a3ca1bcad9..b2b0deee23 100644
--- a/packages/ui/src/components/image-viewer-editor/use-image-editor.ts
+++ b/packages/ui/src/components/image-viewer-editor/use-image-editor.ts
@@ -1353,6 +1353,16 @@ export function useImageEditor() {
applyDisplayScale()
}
+ function waitForRender() {
+ const editorCanvas = canvas.value
+ if (!editorCanvas) return Promise.resolve()
+
+ return new Promise
((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,