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
@@ -67,27 +67,27 @@ const messages = defineMessages({
// }, // },
multiplayerServers: { multiplayerServers: {
id: 'app.settings.synced-options.multiplayer-servers', id: 'app.settings.synced-options.multiplayer-servers',
defaultMessage: 'Multiplayer servers', defaultMessage: 'Sync multiplayer servers',
}, },
multiplayerServersDescription: { multiplayerServersDescription: {
id: 'app.settings.synced-options.multiplayer-servers.description', id: 'app.settings.synced-options.multiplayer-servers.description',
defaultMessage: 'Sync multiplayer servers across your instances.', defaultMessage: 'Use the same multiplayer servers across your instances.',
}, },
commandHistory: { commandHistory: {
id: 'app.settings.synced-options.command-history', id: 'app.settings.synced-options.command-history',
defaultMessage: 'Command history', defaultMessage: 'Sync command history',
}, },
commandHistoryDescription: { commandHistoryDescription: {
id: 'app.settings.synced-options.command-history.description', id: 'app.settings.synced-options.command-history.description',
defaultMessage: 'Sync command history across your instances.', defaultMessage: 'Use the same command history across your instances.',
}, },
creativeHotbars: { creativeHotbars: {
id: 'app.settings.synced-options.creative-hotbars', id: 'app.settings.synced-options.creative-hotbars',
defaultMessage: 'Saved creative hotbars', defaultMessage: 'Sync saved creative hotbars',
}, },
creativeHotbarsDescription: { creativeHotbarsDescription: {
id: 'app.settings.synced-options.creative-hotbars.description', id: 'app.settings.synced-options.creative-hotbars.description',
defaultMessage: 'Sync saved creative hotbars across your instances.', defaultMessage: 'Use the same saved creative hotbars across your instances.',
}, },
chooseSyncSourceTitle: { chooseSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.title', id: 'app.settings.synced-options.choose-sync-source.title',
+21 -21
View File
@@ -1719,28 +1719,28 @@
"message": "Choose a sync source" "message": "Choose a sync source"
}, },
"app.settings.synced-options.command-history": { "app.settings.synced-options.command-history": {
"message": "Command history" "message": "Sync command history"
}, },
"app.settings.synced-options.command-history.description": { "app.settings.synced-options.command-history.description": {
"message": "Sync command history across your instances." "message": "Use the same command history across your instances."
}, },
"app.settings.synced-options.command-history.editor-title": { "app.settings.synced-options.command-history.editor-title": {
"message": "Edit command history" "message": "Edit command history"
}, },
"app.settings.synced-options.creative-hotbars": { "app.settings.synced-options.creative-hotbars": {
"message": "Saved creative hotbars" "message": "Sync saved creative hotbars"
}, },
"app.settings.synced-options.creative-hotbars.description": { "app.settings.synced-options.creative-hotbars.description": {
"message": "Sync saved creative hotbars across your instances." "message": "Use the same saved creative hotbars across your instances."
}, },
"app.settings.synced-options.multiplayer-servers": { "app.settings.synced-options.multiplayer-servers": {
"message": "Multiplayer servers" "message": "Sync multiplayer servers"
}, },
"app.settings.synced-options.multiplayer-servers.address": { "app.settings.synced-options.multiplayer-servers.address": {
"message": "Server address" "message": "Server address"
}, },
"app.settings.synced-options.multiplayer-servers.description": { "app.settings.synced-options.multiplayer-servers.description": {
"message": "Sync multiplayer servers across your instances." "message": "Use the same multiplayer servers across your instances."
}, },
"app.settings.synced-options.multiplayer-servers.editor-title": { "app.settings.synced-options.multiplayer-servers.editor-title": {
"message": "Edit synced servers" "message": "Edit synced servers"
@@ -2589,7 +2589,7 @@
"message": "Select update channel" "message": "Select update channel"
}, },
"instance.settings.tabs.hooks.description": { "instance.settings.tabs.hooks.description": {
"message": "Hooks allow advanced users to run certain system commands before and after launching the game." "message": "Run instance-specific system commands before and after launching the game."
}, },
"instance.settings.tabs.hooks.post-exit": { "instance.settings.tabs.hooks.post-exit": {
"message": "Post-exit" "message": "Post-exit"
@@ -2610,7 +2610,7 @@
"message": "Enter pre-launch command..." "message": "Enter pre-launch command..."
}, },
"instance.settings.tabs.hooks.title": { "instance.settings.tabs.hooks.title": {
"message": "Game launch hooks" "message": "Custom game launch hooks"
}, },
"instance.settings.tabs.hooks.variables.description": { "instance.settings.tabs.hooks.variables.description": {
"message": "Hooks run in the working directory of the instance, with the following variables:" "message": "Hooks run in the working directory of the instance, with the following variables:"
@@ -2652,16 +2652,16 @@
"message": "Installation settings are unavailable while this instance is locked." "message": "Installation settings are unavailable while this instance is locked."
}, },
"instance.settings.tabs.java.custom-environment-variables": { "instance.settings.tabs.java.custom-environment-variables": {
"message": "Use custom environment variables for this instance." "message": "Set environment variables separately for this instance."
}, },
"instance.settings.tabs.java.custom-java-arguments": { "instance.settings.tabs.java.custom-java-arguments": {
"message": "Use custom Java arguments for this instance." "message": "Set Java arguments separately for this instance."
}, },
"instance.settings.tabs.java.custom-java-installation": { "instance.settings.tabs.java.custom-java-installation": {
"message": "Use a custom Java installation for this instance." "message": "Choose a different Java installation for this instance."
}, },
"instance.settings.tabs.java.custom-memory-allocation": { "instance.settings.tabs.java.custom-memory-allocation": {
"message": "Use a custom memory allocation for this instance." "message": "Set the memory allocation separately for this instance."
}, },
"instance.settings.tabs.java.enter-environment-variables": { "instance.settings.tabs.java.enter-environment-variables": {
"message": "Enter environmental variables..." "message": "Enter environmental variables..."
@@ -2670,19 +2670,19 @@
"message": "Enter Java arguments..." "message": "Enter Java arguments..."
}, },
"instance.settings.tabs.java.environment-variables": { "instance.settings.tabs.java.environment-variables": {
"message": "Environment variables" "message": "Custom environment variables"
}, },
"instance.settings.tabs.java.hooks": { "instance.settings.tabs.java.hooks": {
"message": "Hooks" "message": "Hooks"
}, },
"instance.settings.tabs.java.java-arguments": { "instance.settings.tabs.java.java-arguments": {
"message": "Java arguments" "message": "Custom Java arguments"
}, },
"instance.settings.tabs.java.java-installation": { "instance.settings.tabs.java.java-installation": {
"message": "Java installation" "message": "Custom Java installation"
}, },
"instance.settings.tabs.java.java-memory": { "instance.settings.tabs.java.java-memory": {
"message": "Memory allocated" "message": "Custom memory allocation"
}, },
"instance.settings.tabs.java.java-path-placeholder": { "instance.settings.tabs.java.java-path-placeholder": {
"message": "/path/to/java" "message": "/path/to/java"
@@ -2694,7 +2694,7 @@
"message": "Sharing" "message": "Sharing"
}, },
"instance.settings.tabs.synced-options.command-history": { "instance.settings.tabs.synced-options.command-history": {
"message": "Command history" "message": "Unsync command history"
}, },
"instance.settings.tabs.synced-options.command-history.disabled-in-app": { "instance.settings.tabs.synced-options.command-history.disabled-in-app": {
"message": "Command history syncing is turned off in app settings." "message": "Command history syncing is turned off in app settings."
@@ -2703,7 +2703,7 @@
"message": "Keep this instance's command history separate from synced command history." "message": "Keep this instance's command history separate from synced command history."
}, },
"instance.settings.tabs.synced-options.creative-hotbars": { "instance.settings.tabs.synced-options.creative-hotbars": {
"message": "Saved creative hotbars" "message": "Unsync saved creative hotbars"
}, },
"instance.settings.tabs.synced-options.creative-hotbars.disabled-in-app": { "instance.settings.tabs.synced-options.creative-hotbars.disabled-in-app": {
"message": "Saved creative hotbar syncing is turned off in app settings." "message": "Saved creative hotbar syncing is turned off in app settings."
@@ -2727,7 +2727,7 @@
"message": "Use synced" "message": "Use synced"
}, },
"instance.settings.tabs.synced-options.multiplayer-servers": { "instance.settings.tabs.synced-options.multiplayer-servers": {
"message": "Multiplayer servers" "message": "Unsync multiplayer servers"
}, },
"instance.settings.tabs.synced-options.multiplayer-servers.disabled-in-app": { "instance.settings.tabs.synced-options.multiplayer-servers.disabled-in-app": {
"message": "Multiplayer server syncing is turned off in app settings." "message": "Multiplayer server syncing is turned off in app settings."
@@ -2742,10 +2742,10 @@
"message": "Enable an override to keep a synced setting separate for this instance." "message": "Enable an override to keep a synced setting separate for this instance."
}, },
"instance.settings.tabs.window": { "instance.settings.tabs.window": {
"message": "Window" "message": "Custom window settings"
}, },
"instance.settings.tabs.window.custom-window-settings": { "instance.settings.tabs.window.custom-window-settings": {
"message": "Use custom window settings for this instance." "message": "Configure fullscreen and launch resolution separately for this instance."
}, },
"instance.settings.tabs.window.fullscreen": { "instance.settings.tabs.window.fullscreen": {
"message": "Fullscreen" "message": "Fullscreen"
@@ -61,12 +61,11 @@ watch(
const messages = defineMessages({ const messages = defineMessages({
hooks: { hooks: {
id: 'instance.settings.tabs.hooks.title', id: 'instance.settings.tabs.hooks.title',
defaultMessage: 'Game launch hooks', defaultMessage: 'Custom game launch hooks',
}, },
hooksDescription: { hooksDescription: {
id: 'instance.settings.tabs.hooks.description', id: 'instance.settings.tabs.hooks.description',
defaultMessage: defaultMessage: 'Run instance-specific system commands before and after launching the game.',
'Hooks allow advanced users to run certain system commands before and after launching the game.',
}, },
hookVariablesDescription: { hookVariablesDescription: {
id: 'instance.settings.tabs.hooks.variables.description', id: 'instance.settings.tabs.hooks.variables.description',
@@ -155,11 +155,11 @@ watch(
const messages = defineMessages({ const messages = defineMessages({
javaInstallation: { javaInstallation: {
id: 'instance.settings.tabs.java.java-installation', id: 'instance.settings.tabs.java.java-installation',
defaultMessage: 'Java installation', defaultMessage: 'Custom Java installation',
}, },
customJavaInstallation: { customJavaInstallation: {
id: 'instance.settings.tabs.java.custom-java-installation', id: 'instance.settings.tabs.java.custom-java-installation',
defaultMessage: 'Use a custom Java installation for this instance.', defaultMessage: 'Choose a different Java installation for this instance.',
}, },
javaPathPlaceholder: { javaPathPlaceholder: {
id: 'instance.settings.tabs.java.java-path-placeholder', id: 'instance.settings.tabs.java.java-path-placeholder',
@@ -167,19 +167,19 @@ const messages = defineMessages({
}, },
javaMemory: { javaMemory: {
id: 'instance.settings.tabs.java.java-memory', id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated', defaultMessage: 'Custom memory allocation',
}, },
customMemoryAllocation: { customMemoryAllocation: {
id: 'instance.settings.tabs.java.custom-memory-allocation', id: 'instance.settings.tabs.java.custom-memory-allocation',
defaultMessage: 'Use a custom memory allocation for this instance.', defaultMessage: 'Set the memory allocation separately for this instance.',
}, },
javaArguments: { javaArguments: {
id: 'instance.settings.tabs.java.java-arguments', id: 'instance.settings.tabs.java.java-arguments',
defaultMessage: 'Java arguments', defaultMessage: 'Custom Java arguments',
}, },
customJavaArguments: { customJavaArguments: {
id: 'instance.settings.tabs.java.custom-java-arguments', id: 'instance.settings.tabs.java.custom-java-arguments',
defaultMessage: 'Use custom Java arguments for this instance.', defaultMessage: 'Set Java arguments separately for this instance.',
}, },
enterJavaArguments: { enterJavaArguments: {
id: 'instance.settings.tabs.java.enter-java-arguments', id: 'instance.settings.tabs.java.enter-java-arguments',
@@ -187,11 +187,11 @@ const messages = defineMessages({
}, },
javaEnvironmentVariables: { javaEnvironmentVariables: {
id: 'instance.settings.tabs.java.environment-variables', id: 'instance.settings.tabs.java.environment-variables',
defaultMessage: 'Environment variables', defaultMessage: 'Custom environment variables',
}, },
customEnvironmentVariables: { customEnvironmentVariables: {
id: 'instance.settings.tabs.java.custom-environment-variables', id: 'instance.settings.tabs.java.custom-environment-variables',
defaultMessage: 'Use custom environment variables for this instance.', defaultMessage: 'Set environment variables separately for this instance.',
}, },
enterEnvironmentVariables: { enterEnvironmentVariables: {
id: 'instance.settings.tabs.java.enter-environment-variables', id: 'instance.settings.tabs.java.enter-environment-variables',
@@ -45,7 +45,7 @@ const messages = defineMessages({
}, },
multiplayerServers: { multiplayerServers: {
id: 'instance.settings.tabs.synced-options.multiplayer-servers', id: 'instance.settings.tabs.synced-options.multiplayer-servers',
defaultMessage: 'Multiplayer servers', defaultMessage: 'Unsync multiplayer servers',
}, },
multiplayerServersDescription: { multiplayerServersDescription: {
id: 'instance.settings.tabs.synced-options.multiplayer-servers.override-description', id: 'instance.settings.tabs.synced-options.multiplayer-servers.override-description',
@@ -57,7 +57,7 @@ const messages = defineMessages({
}, },
commandHistory: { commandHistory: {
id: 'instance.settings.tabs.synced-options.command-history', id: 'instance.settings.tabs.synced-options.command-history',
defaultMessage: 'Command history', defaultMessage: 'Unsync command history',
}, },
commandHistoryDescription: { commandHistoryDescription: {
id: 'instance.settings.tabs.synced-options.command-history.override-description', id: 'instance.settings.tabs.synced-options.command-history.override-description',
@@ -69,7 +69,7 @@ const messages = defineMessages({
}, },
creativeHotbars: { creativeHotbars: {
id: 'instance.settings.tabs.synced-options.creative-hotbars', id: 'instance.settings.tabs.synced-options.creative-hotbars',
defaultMessage: 'Saved creative hotbars', defaultMessage: 'Unsync saved creative hotbars',
}, },
creativeHotbarsDescription: { creativeHotbarsDescription: {
id: 'instance.settings.tabs.synced-options.creative-hotbars.override-description', id: 'instance.settings.tabs.synced-options.creative-hotbars.override-description',
@@ -56,11 +56,11 @@ watch(
const messages = defineMessages({ const messages = defineMessages({
window: { window: {
id: 'instance.settings.tabs.window', id: 'instance.settings.tabs.window',
defaultMessage: 'Window', defaultMessage: 'Custom window settings',
}, },
customWindowSettings: { customWindowSettings: {
id: 'instance.settings.tabs.window.custom-window-settings', id: 'instance.settings.tabs.window.custom-window-settings',
defaultMessage: 'Use custom window settings for this instance.', defaultMessage: 'Configure fullscreen and launch resolution separately for this instance.',
}, },
fullscreen: { fullscreen: {
id: 'instance.settings.tabs.window.fullscreen', id: 'instance.settings.tabs.window.fullscreen',
@@ -43,7 +43,6 @@ const emit = defineEmits<{
cancel: [] cancel: []
close: [] close: []
edit: [] edit: []
imageReady: []
next: [] next: []
previous: [] previous: []
save: [payload: ImageViewerEditorSavePayload] save: [payload: ImageViewerEditorSavePayload]
@@ -85,6 +84,7 @@ const {
fitToViewport, fitToViewport,
setZoom, setZoom,
setFit, setFit,
waitForRender,
exportPng, exportPng,
handleKeyboardShortcut, handleKeyboardShortcut,
isTextEditing, 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 viewZoomPercent = computed(() => Math.round(viewZoom.value * 100))
const viewZoomed = computed(() => viewZoom.value > 1.001) const viewZoomed = computed(() => viewZoom.value > 1.001)
const canViewZoomIn = computed(() => canZoomIn.value && viewZoom.value < MAX_VIEW_ZOOM) 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 resizeObserver: ResizeObserver | undefined
let initializationGeneration = 0 let initializationGeneration = 0
let initializationChain = Promise.resolve() 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() { function queueInitialization() {
const generation = ++initializationGeneration const generation = ++initializationGeneration
@@ -120,7 +140,6 @@ function queueInitialization() {
const editorDataPromise = props.loadData(props.item) const editorDataPromise = props.loadData(props.item)
initializationChain = initializationChain.then(async () => { initializationChain = initializationChain.then(async () => {
if (generation !== initializationGeneration) return if (generation !== initializationGeneration) return
let initialized = false
try { try {
const editorData = await editorDataPromise const editorData = await editorDataPromise
if (generation !== initializationGeneration || !canvasElement.value) return if (generation !== initializationGeneration || !canvasElement.value) return
@@ -128,7 +147,8 @@ function queueInitialization() {
if (generation !== initializationGeneration) return if (generation !== initializationGeneration) return
setInteractionEnabled(props.mode === 'edit') setInteractionEnabled(props.mode === 'edit')
observeViewport() observeViewport()
initialized = true await waitForRender()
if (generation !== initializationGeneration) return
} catch (error) { } catch (error) {
if (generation !== initializationGeneration) return if (generation !== initializationGeneration) return
handleError(error) handleError(error)
@@ -136,20 +156,11 @@ function queueInitialization() {
} finally { } finally {
if (generation === initializationGeneration) { if (generation === initializationGeneration) {
loadingEditorData.value = false loadingEditorData.value = false
if (initialized) notifyImageReady()
} }
} }
}) })
} }
function notifyImageReady() {
if (imageReadyFrame !== undefined) cancelAnimationFrame(imageReadyFrame)
imageReadyFrame = requestAnimationFrame(() => {
imageReadyFrame = undefined
emit('imageReady')
})
}
function observeViewport() { function observeViewport() {
resizeObserver?.disconnect() resizeObserver?.disconnect()
if (!viewport.value || !fitBounds.value) return if (!viewport.value || !fitBounds.value) return
@@ -164,7 +175,6 @@ function fitEditorToViewport() {
if (!viewportSize) return if (!viewportSize) return
fitToViewport(viewportSize.width, viewportSize.height) fitToViewport(viewportSize.width, viewportSize.height)
if (isFit.value) centerViewport() if (isFit.value) centerViewport()
else notifyImageReady()
} }
function getViewportSize() { function getViewportSize() {
@@ -186,72 +196,9 @@ function centerViewport() {
0, 0,
(viewport.value.scrollHeight - viewport.value.clientHeight) / 2, (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() { function resetView() {
setFit() setFit()
centerViewport() centerViewport()
@@ -360,10 +307,7 @@ function stopPan() {
const pan = panning.value const pan = panning.value
panning.value = undefined panning.value = undefined
if (!pan || props.mode !== 'view') return if (!pan || props.mode !== 'view') return
if (pan.moved) { if (pan.moved) return
notifyImageReady()
return
}
if (viewZoomed.value) resetView() if (viewZoomed.value) resetView()
else setViewZoom(CLICK_ZOOM) else setViewZoom(CLICK_ZOOM)
} }
@@ -427,7 +371,6 @@ onMounted(async () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
initializationGeneration++ initializationGeneration++
if (imageReadyFrame !== undefined) cancelAnimationFrame(imageReadyFrame)
document.removeEventListener('keydown', handleKeydown) document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('keyup', handleKeyup) document.removeEventListener('keyup', handleKeyup)
document.removeEventListener('edit-menu:undo', handleEditMenuUndo) document.removeEventListener('edit-menu:undo', handleEditMenuUndo)
@@ -436,7 +379,7 @@ onBeforeUnmount(() => {
void dispose() void dispose()
}) })
defineExpose({ getTextContrast, markSaved }) defineExpose({ markSaved })
</script> </script>
<template> <template>
@@ -456,20 +399,31 @@ defineExpose({ getTextContrast, markSaved })
@wheel="handleWheel" @wheel="handleWheel"
> >
<img <img
v-if="mode === 'view' && loadingEditorData" v-if="mode === 'view' && loadingEditorData && !nativeImageView"
:src="item.src" :src="item.src"
:alt="item.alt" :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" draggable="false"
/> />
<div <div
v-show="!loadingEditorData" v-show="nativeImageView || !loadingEditorData"
class="editor-canvas relative z-[2] m-auto shrink-0" class="editor-canvas relative z-[2] m-auto shrink-0"
:class="{
'h-full w-full': nativeImageView && loadingEditorData,
'is-native-image-view': nativeImageView,
}"
@pointerenter="updateBrushPointer" @pointerenter="updateBrushPointer"
@pointermove="updateBrushPointer" @pointermove="updateBrushPointer"
@pointerleave="brushPointer.visible = false" @pointerleave="brushPointer.visible = false"
> >
<canvas ref="canvasElement" :aria-label="item.alt" role="img" /> <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 <div
v-if="hasBrushPointer && brushPointer.visible" 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%)]" 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%); 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) { .editor-viewport.is-view :deep(.upper-canvas) {
cursor: zoom-in !important; cursor: zoom-in !important;
} }
@@ -34,16 +34,11 @@ const emit = defineEmits<{
const activeId = ref<string | null>(null) const activeId = ref<string | null>(null)
const mode = ref<ImageViewerEditorMode>('view') const mode = ref<ImageViewerEditorMode>('view')
const titleContrast = ref<'dark' | 'light'>('light')
const descriptionContrast = ref<'dark' | 'light'>('light')
const closeAfterEditing = ref(false) const closeAfterEditing = ref(false)
const editorComponent = ref<InstanceType<typeof Editor>>() const editorComponent = ref<InstanceType<typeof Editor>>()
const titleElement = ref<HTMLElement>()
const descriptionElement = ref<HTMLElement>()
const context = injectImageViewerEditor(null) const context = injectImageViewerEditor(null)
const itemDataCache = new Map<string, Promise<ImageViewerEditorData>>() const itemDataCache = new Map<string, Promise<ImageViewerEditorData>>()
const itemImageCache = new Map<string, HTMLImageElement>() const itemImageCache = new Map<string, HTMLImageElement>()
let headingContrastTimer: ReturnType<typeof setTimeout> | undefined
const activeIndex = computed(() => props.items.findIndex((item) => item.id === activeId.value)) const activeIndex = computed(() => props.items.findIndex((item) => item.id === activeId.value))
const activeItem = computed(() => props.items[activeIndex.value] ?? null) const activeItem = computed(() => props.items[activeIndex.value] ?? null)
@@ -129,11 +124,8 @@ function preloadItemsAround(index: number) {
function show(index: number) { function show(index: number) {
const item = props.items[index] const item = props.items[index]
if (!item) return if (!item) return
cancelHeadingContrastUpdate()
preloadItemsAround(index) preloadItemsAround(index)
if (activeId.value === null) { if (activeId.value === null) {
titleContrast.value = 'light'
descriptionContrast.value = 'light'
context?.onShow?.() context?.onShow?.()
} }
activeId.value = item.id activeId.value = item.id
@@ -165,7 +157,6 @@ function finishEditing() {
function hide() { function hide() {
if (activeId.value === null || props.saving) return if (activeId.value === null || props.saving) return
cancelHeadingContrastUpdate()
activeId.value = null activeId.value = null
mode.value = 'view' mode.value = 'view'
closeAfterEditing.value = false closeAfterEditing.value = false
@@ -177,33 +168,12 @@ function hide() {
function navigate(offset: number, direction: 'next' | 'previous') { function navigate(offset: number, direction: 'next' | 'previous') {
if (mode.value !== 'view' || props.items.length < 2) return if (mode.value !== 'view' || props.items.length < 2) return
cancelHeadingContrastUpdate()
const index = (activeIndex.value + offset + props.items.length) % props.items.length const index = (activeIndex.value + offset + props.items.length) % props.items.length
preloadItemsAround(index) preloadItemsAround(index)
activeId.value = props.items[index].id activeId.value = props.items[index].id
emit('navigate', props.items[index], index, direction) 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() { function next() {
navigate(1, 'next') navigate(1, 'next')
} }
@@ -237,7 +207,6 @@ function handleKeydown(event: KeyboardEvent) {
onMounted(() => document.addEventListener('keydown', handleKeydown)) onMounted(() => document.addEventListener('keydown', handleKeydown))
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeydown) document.removeEventListener('keydown', handleKeydown)
cancelHeadingContrastUpdate()
itemDataCache.clear() itemDataCache.clear()
itemImageCache.clear() itemImageCache.clear()
if (activeId.value !== null) context?.onHide?.() 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" class="absolute inset-x-6 top-[calc(var(--top-bar-height,3rem)_+_1.5rem)] z-10 min-w-0"
@click.stop @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 <h2
v-if="activeItem.title" v-if="activeItem.title"
ref="titleElement" class="m-0 max-w-[min(42rem,70vw)] truncate text-base font-semibold leading-snug text-contrast"
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 }} {{ activeItem.title }}
</h2> </h2>
<p <p
v-if="activeItem.description" v-if="activeItem.description"
ref="descriptionElement" class="mb-0 mt-1 max-w-[min(42rem,70vw)] truncate text-xs leading-snug text-white"
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 }} {{ activeItem.description }}
</p> </p>
@@ -296,7 +261,6 @@ defineExpose({ show, edit, hide, next, previous, markSavedAndView })
@previous="previous" @previous="previous"
@cancel="finishEditing" @cancel="finishEditing"
@save="emit('save', $event)" @save="emit('save', $event)"
@image-ready="updateHeadingContrast"
> >
<template #actions> <template #actions>
<slot name="actions" :item="activeItem" :index="activeIndex" :hide="hide" /> <slot name="actions" :item="activeItem" :index="activeIndex" :hide="hide" />
@@ -305,3 +269,9 @@ defineExpose({ show, edit, hide, next, previous, markSavedAndView })
</div> </div>
</Teleport> </Teleport>
</template> </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() 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() { function applyDisplayScale() {
const editorCanvas = canvas.value const editorCanvas = canvas.value
if (!editorCanvas) return if (!editorCanvas) return
@@ -1621,6 +1631,7 @@ export function useImageEditor() {
fitToViewport, fitToViewport,
setZoom, setZoom,
setFit, setFit,
waitForRender,
exportPng, exportPng,
handleKeyboardShortcut, handleKeyboardShortcut,
isTextEditing, isTextEditing,