mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
fix: gallery issues
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user