fix: dragging disable hover, wrong evt for edit skin + remove back of skin hover

This commit is contained in:
Calum H. (IMB11)
2026-06-10 20:56:53 +01:00
parent d60fcc67d6
commit f5204da51e
8 changed files with 127 additions and 100 deletions
@@ -148,7 +148,6 @@ import { arrayBufferToBase64 } from '@modrinth/utils'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue' import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { import {
add_and_equip_custom_skin,
type Cape, type Cape,
determineModelType, determineModelType,
equip_skin, equip_skin,
@@ -440,9 +439,22 @@ async function save() {
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer()) const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
if (mode.value === 'new') { if (mode.value === 'new') {
const addedSkin = await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value) const addedSkin = await save_custom_skin(
{
texture_key: '',
variant: variant.value,
cape_id: selectedCape.value?.id,
texture: textureUrl,
source: 'custom',
is_equipped: false,
},
bytes,
variant.value,
selectedCape.value,
true,
)
emit('saved', { emit('saved', {
applied: true, applied: false,
skin: addedSkin, skin: addedSkin,
}) })
} else { } else {
@@ -159,6 +159,9 @@ const sections = computed<SkinSection[]>(() => [
const draggableSavedSkins = ref<Skin[]>([]) const draggableSavedSkins = ref<Skin[]>([])
const isDraggingSavedSkin = ref(false) const isDraggingSavedSkin = ref(false)
const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1) const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1)
const fixedSavedSkins = computed(() =>
props.savedSkins.filter((skin) => !canPersistSkinOrder(skin)),
)
const sectionLayouts = computed(() => { const sectionLayouts = computed(() => {
const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = [] const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = []
@@ -223,7 +226,7 @@ watch(
return return
} }
draggableSavedSkins.value = [...nextSkins] draggableSavedSkins.value = nextSkins.filter(canPersistSkinOrder)
}, },
{ immediate: true }, { immediate: true },
) )
@@ -280,10 +283,18 @@ function savedSkinKey(skin: Skin) {
return skinKey(skin, 'saved-skin') return skinKey(skin, 'saved-skin')
} }
function canPersistSkinOrder(skin: Skin) {
return skin.source === 'custom'
}
function doSkinOrdersMatch(firstSkins: Skin[], secondSkins: Skin[]) { function doSkinOrdersMatch(firstSkins: Skin[], secondSkins: Skin[]) {
const persistedSecondSkins = secondSkins.filter(canPersistSkinOrder)
return ( return (
firstSkins.length === secondSkins.length && firstSkins.length === persistedSecondSkins.length &&
firstSkins.every((skin, index) => savedSkinKey(skin) === savedSkinKey(secondSkins[index])) firstSkins.every(
(skin, index) => savedSkinKey(skin) === savedSkinKey(persistedSecondSkins[index]),
)
) )
} }
@@ -295,7 +306,7 @@ function onSavedSkinDragEnd() {
isDraggingSavedSkin.value = false isDraggingSavedSkin.value = false
if (doSkinOrdersMatch(draggableSavedSkins.value, props.savedSkins)) { if (doSkinOrdersMatch(draggableSavedSkins.value, props.savedSkins)) {
draggableSavedSkins.value = [...props.savedSkins] draggableSavedSkins.value = props.savedSkins.filter(canPersistSkinOrder)
return return
} }
@@ -404,7 +415,7 @@ defineExpose({ getAddSkinButtonElement })
:list="draggableSavedSkins" :list="draggableSavedSkins"
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6" class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
:item-key="savedSkinKey" :item-key="savedSkinKey"
:disabled="!canReorderSavedSkins" :disabled="readOnly || !canReorderSavedSkins"
:animation="250" :animation="250"
:swap-threshold="1" :swap-threshold="1"
:invert-swap="false" :invert-swap="false"
@@ -447,10 +458,50 @@ defineExpose({ getAddSkinButtonElement })
<SkinButton <SkinButton
class="h-full w-full min-w-0 box-border rounded-[20px]" class="h-full w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards" :forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)" :selected="isSkinSelected(skin)"
:active="isSkinActive(skin)" :active="isSkinActive(skin)"
:disabled="readOnly" :disabled="readOnly"
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
<button
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</template>
</SkinButton>
</div>
</template>
<template #footer>
<div
v-for="skin in fixedSavedSkins"
:key="savedSkinKey(skin)"
class="relative aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
>
<SkinButton
class="h-full w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
:disabled="readOnly"
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)" @select="emit('select', skin)"
> >
<template v-if="!readOnly" #overlay-buttons> <template v-if="!readOnly" #overlay-buttons>
@@ -488,11 +539,11 @@ defineExpose({ getAddSkinButtonElement })
:key="skinKey(skin, section.key)" :key="skinKey(skin, section.key)"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]" class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards" :forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)" :selected="isSkinSelected(skin)"
:active="isSkinActive(skin)" :active="isSkinActive(skin)"
:tooltip="skin.name" :tooltip="skin.name"
:disabled="readOnly" :disabled="readOnly"
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)" @select="emit('select', skin)"
> >
<template #overlay-buttons> <template #overlay-buttons>
@@ -15,12 +15,10 @@ import { skinPreviewStorage } from '../storage/skin-preview-storage'
export interface RenderResult { export interface RenderResult {
forwards: string forwards: string
backwards: string
} }
export interface RawRenderResult { export interface RawRenderResult {
forwards: Blob forwards: Blob
backwards: Blob
} }
class BatchSkinRenderer { class BatchSkinRenderer {
@@ -92,12 +90,9 @@ class BatchSkinRenderer {
} }
const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3] const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
const backCameraPos: [number, number, number] = [-1.3, 1, -2.5]
const forwards = await this.renderView(frontCameraPos, lookAtTarget) const forwards = await this.renderView(frontCameraPos, lookAtTarget)
const backwards = await this.renderView(backCameraPos, lookAtTarget)
return { forwards, backwards } return { forwards }
} }
private async renderView( private async renderView(
@@ -407,7 +402,6 @@ async function generateSkinPreviewsForGeneration(
if (rawCached && !skinBlobUrlMap.has(skinKey)) { if (rawCached && !skinBlobUrlMap.has(skinKey)) {
const cached: RenderResult = { const cached: RenderResult = {
forwards: URL.createObjectURL(rawCached.forwards), forwards: URL.createObjectURL(rawCached.forwards),
backwards: URL.createObjectURL(rawCached.backwards),
} }
skinBlobUrlMap.set(skinKey, cached) skinBlobUrlMap.set(skinKey, cached)
} }
@@ -427,7 +421,6 @@ async function generateSkinPreviewsForGeneration(
if (DEBUG_MODE) { if (DEBUG_MODE) {
const result = skinBlobUrlMap.get(key)! const result = skinBlobUrlMap.get(key)!
URL.revokeObjectURL(result.forwards) URL.revokeObjectURL(result.forwards)
URL.revokeObjectURL(result.backwards)
skinBlobUrlMap.delete(key) skinBlobUrlMap.delete(key)
} else continue } else continue
} }
@@ -456,7 +449,6 @@ async function generateSkinPreviewsForGeneration(
const renderResult: RenderResult = { const renderResult: RenderResult = {
forwards: URL.createObjectURL(rawRenderResult.forwards), forwards: URL.createObjectURL(rawRenderResult.forwards),
backwards: URL.createObjectURL(rawRenderResult.backwards),
} }
skinBlobUrlMap.set(key, renderResult) skinBlobUrlMap.set(key, renderResult)
@@ -2,7 +2,6 @@ import type { RawRenderResult } from '../rendering/batch-skin-renderer'
interface StoredPreview { interface StoredPreview {
forwards: Blob forwards: Blob
backwards: Blob
timestamp: number timestamp: number
} }
@@ -38,7 +37,6 @@ export class SkinPreviewStorage {
const storedPreview: StoredPreview = { const storedPreview: StoredPreview = {
forwards: result.forwards, forwards: result.forwards,
backwards: result.backwards,
timestamp: Date.now(), timestamp: Date.now(),
} }
@@ -67,7 +65,7 @@ export class SkinPreviewStorage {
return return
} }
resolve({ forwards: result.forwards, backwards: result.backwards }) resolve({ forwards: result.forwards })
} }
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
@@ -95,7 +93,7 @@ export class SkinPreviewStorage {
const result = request.result as StoredPreview | undefined const result = request.result as StoredPreview | undefined
if (result) { if (result) {
results[key] = { forwards: result.forwards, backwards: result.backwards } results[key] = { forwards: result.forwards }
} else { } else {
results[key] = null results[key] = null
} }
@@ -173,7 +171,7 @@ export class SkinPreviewStorage {
const key = cursor.primaryKey as string const key = cursor.primaryKey as string
const value = cursor.value as StoredPreview const value = cursor.value as StoredPreview
const entrySize = value.forwards.size + value.backwards.size const entrySize = value.forwards.size
totalSize += entrySize totalSize += entrySize
count++ count++
+3 -1
View File
@@ -584,7 +584,9 @@ async function reorderSavedSkins(orderedSkins: Skin[]) {
generateSkinPreviews(skins.value, capes.value) generateSkinPreviews(skins.value, capes.value)
try { try {
await set_custom_skin_order(nextSavedSkins.map((skin) => skin.texture_key)) await set_custom_skin_order(
nextSavedSkins.filter((skin) => skin.source === 'custom').map((skin) => skin.texture_key),
)
} catch (error) { } catch (error) {
skins.value = previousSkins skins.value = previousSkins
generateSkinPreviews(skins.value, capes.value) generateSkinPreviews(skins.value, capes.value)
+33 -17
View File
@@ -66,8 +66,7 @@ use crate::{
state::{ state::{
MinecraftCharacterExpressionState, MinecraftProfile, MinecraftCharacterExpressionState, MinecraftProfile,
minecraft_skins::{ minecraft_skins::{
CustomMinecraftSkin, CustomMinecraftSkinInsertPosition, CustomMinecraftSkin, CustomMinecraftSkinInsertPosition, mojang_api,
mojang_api,
}, },
}, },
}; };
@@ -610,20 +609,21 @@ async fn add_and_equip_custom_skin_now(
let equipped_skin = profile.current_skin()?; let equipped_skin = profile.current_skin()?;
let equipped_skin_texture_key = equipped_skin.texture_key(); let equipped_skin_texture_key = equipped_skin.texture_key();
let equipped_skin_variant = equipped_skin.variant; let equipped_skin_variant = equipped_skin.variant;
let insert_position = let insert_position = if local_texture_key
if local_texture_key != equipped_skin_texture_key.as_ref() { != equipped_skin_texture_key.as_ref()
CustomMinecraftSkin::get_by_texture( {
profile.id, CustomMinecraftSkin::get_by_texture(
local_texture_key, profile.id,
&state.pool, local_texture_key,
) &state.pool,
.await? )
.map_or(CustomMinecraftSkinInsertPosition::Top, |skin| { .await?
CustomMinecraftSkinInsertPosition::At(skin.display_order) .map_or(CustomMinecraftSkinInsertPosition::Top, |skin| {
}) CustomMinecraftSkinInsertPosition::At(skin.display_order)
} else { })
CustomMinecraftSkinInsertPosition::Top } else {
}; CustomMinecraftSkinInsertPosition::Top
};
let persistence_result = if cape_id.is_none() let persistence_result = if cape_id.is_none()
&& is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant) && is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant)
@@ -762,6 +762,22 @@ async fn persist_equipped_skin(
let equipped_skin_variant = equipped_skin.variant; let equipped_skin_variant = equipped_skin.variant;
let texture_key_changed = let texture_key_changed =
skin.texture_key.as_ref() != equipped_skin_texture_key.as_ref(); skin.texture_key.as_ref() != equipped_skin_texture_key.as_ref();
let insert_position = if texture_key_changed {
CustomMinecraftSkin::get_by_texture(
profile.id,
&skin.texture_key,
&state.pool,
)
.await?
.map_or(
CustomMinecraftSkinInsertPosition::Bottom,
|saved_skin| {
CustomMinecraftSkinInsertPosition::At(saved_skin.display_order)
},
)
} else {
CustomMinecraftSkinInsertPosition::Bottom
};
if skin.cape_id.is_none() if skin.cape_id.is_none()
&& is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant) && is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant)
@@ -782,7 +798,7 @@ async fn persist_equipped_skin(
texture_blob, texture_blob,
equipped_skin_variant, equipped_skin_variant,
skin.cape_id, skin.cape_id,
CustomMinecraftSkinInsertPosition::Bottom, insert_position,
&state.pool, &state.pool,
) )
.await?; .await?;
+14 -44
View File
@@ -9,28 +9,27 @@ const emit = defineEmits<{
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
forwardImageSrc?: string forwardImageSrc?: string
backwardImageSrc?: string
selected: boolean selected: boolean
active?: boolean active?: boolean
tooltip?: string tooltip?: string
disabled?: boolean disabled?: boolean
isDragging?: boolean
}>(), }>(),
{ {
forwardImageSrc: undefined, forwardImageSrc: undefined,
backwardImageSrc: undefined,
active: false, active: false,
tooltip: undefined, tooltip: undefined,
disabled: false, disabled: false,
isDragging: false,
}, },
) )
const imagesLoaded = ref({ const imagesLoaded = ref({
forward: false, forward: false,
backward: false,
}) })
function onImageLoad(type: 'forward' | 'backward') { function onImageLoad() {
imagesLoaded.value[type] = true imagesLoaded.value.forward = true
} }
watch( watch(
@@ -39,13 +38,6 @@ watch(
imagesLoaded.value.forward = false imagesLoaded.value.forward = false
}, },
) )
watch(
() => props.backwardImageSrc,
() => {
imagesLoaded.value.backward = false
},
)
</script> </script>
<template> <template>
@@ -58,6 +50,7 @@ watch(
{ {
'skin-button--with-actions': $slots['overlay-buttons'] && !disabled, 'skin-button--with-actions': $slots['overlay-buttons'] && !disabled,
'skin-button--disabled': disabled, 'skin-button--disabled': disabled,
'skin-button--dragging': isDragging,
}, },
]" ]"
> >
@@ -81,15 +74,12 @@ watch(
class="pointer-events-none absolute right-3 top-3 z-20 size-3 rounded-full border-2 border-solid border-surface-3 bg-green" class="pointer-events-none absolute right-3 top-3 z-20 size-3 rounded-full border-2 border-solid border-surface-3 bg-green"
></span> ></span>
<div <div v-if="!imagesLoaded.forward" class="skeleton-loader h-full w-full">
v-if="!(imagesLoaded.forward && imagesLoaded.backward)"
class="skeleton-loader h-full w-full"
>
<div class="skeleton absolute inset-0 aspect-[5/7]"></div> <div class="skeleton absolute inset-0 aspect-[5/7]"></div>
</div> </div>
<span <span
v-show="imagesLoaded.forward && imagesLoaded.backward" v-show="imagesLoaded.forward"
:key="`${selected}-${active}`" :key="`${selected}-${active}`"
:class="[ :class="[
'skin-button__image-parent pointer-events-none relative z-0 mb-[1.5px] grid place-items-stretch with-shadow', 'skin-button__image-parent pointer-events-none relative z-0 mb-[1.5px] grid place-items-stretch with-shadow',
@@ -100,14 +90,7 @@ watch(
:src="forwardImageSrc" :src="forwardImageSrc"
class="skin-button__image-facing col-start-1 row-start-1 h-full w-full object-contain" class="skin-button__image-facing col-start-1 row-start-1 h-full w-full object-contain"
height="504" height="504"
@load="onImageLoad('forward')" @load="onImageLoad"
/>
<img
alt=""
:src="backwardImageSrc"
class="skin-button__image-away col-start-1 row-start-1 h-full w-full object-contain"
height="504"
@load="onImageLoad('backward')"
/> />
</span> </span>
@@ -202,30 +185,17 @@ watch(
cursor: not-allowed; cursor: not-allowed;
} }
.skin-button--dragging {
pointer-events: none;
}
.skin-button__image-parent { .skin-button__image-parent {
width: 100%; width: 100%;
height: 95%; height: 95%;
transform: rotateY(0deg) translateZ(0);
transform-style: preserve-3d;
transition: transform 500ms cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
} }
.skin-button:not(.skin-button--disabled):hover .skin-button__image-parent { .skin-button__image-facing {
transform: rotateY(180deg) translateZ(0); transform: translateZ(0);
}
.skin-button__image-facing,
.skin-button__image-away {
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform: translateZ(0.1px);
}
.skin-button__image-away {
transform: rotateY(180deg) translateZ(0.1px);
} }
.with-shadow img { .with-shadow img {
@@ -21,25 +21,11 @@ const frontImage = `data:image/svg+xml,${encodeURIComponent(`
</svg> </svg>
)}`)}` )}`)}`
const backImage = `data:image/svg+xml,${encodeURIComponent(`
<svg width="114" height="176" viewBox="0 0 114 176" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="36" y="10" width="42" height="46" fill="#5A3828"/>
<rect x="28" y="57" width="58" height="60" fill="#1E7FAC"/>
<rect x="18" y="63" width="18" height="54" fill="#2693C7"/>
<rect x="78" y="63" width="18" height="54" fill="#19759E"/>
<rect x="22" y="117" width="14" height="43" fill="#A96A4D"/>
<rect x="78" y="117" width="14" height="43" fill="#A96A4D"/>
<rect x="36" y="117" width="18" height="55" fill="#1D334F"/>
<rect x="60" y="117" width="18" height="55" fill="#162B45"/>
</svg>
)}`)}`
const meta = { const meta = {
title: 'Skin/SkinButton', title: 'Skin/SkinButton',
component: SkinButton, component: SkinButton,
args: { args: {
forwardImageSrc: frontImage, forwardImageSrc: frontImage,
backwardImageSrc: backImage,
selected: false, selected: false,
active: false, active: false,
tooltip: 'Steve', tooltip: 'Steve',