feat: drag and drop skins to reorder

This commit is contained in:
Calum H. (IMB11)
2026-06-09 13:46:47 +01:00
parent 3258d7dbdf
commit 0f12b440c6
23 changed files with 700 additions and 194 deletions
+2 -1
View File
@@ -44,7 +44,8 @@
"vue": "^3.5.13",
"vue-i18n": "^10.0.0",
"vue-router": "^4.6.0",
"vue-virtual-scroller": "v2.0.0-beta.8"
"vue-virtual-scroller": "v2.0.0-beta.8",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@eslint/compat": "^1.1.1",
@@ -1,5 +1,12 @@
<script setup lang="ts">
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
import {
DropdownIcon,
EditIcon,
GripVerticalIcon,
PlusIcon,
TrashIcon,
UnknownIcon,
} from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
@@ -13,6 +20,7 @@ import {
import { useElementSize, useWindowSize } from '@vueuse/core'
import { Tooltip } from 'floating-vue'
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import Draggable from 'vuedraggable'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins.ts'
@@ -73,6 +81,18 @@ const messages = defineMessages({
id: 'app.skins.delete-button',
defaultMessage: 'Delete skin',
},
reorderButton: {
id: 'app.skins.section.saved-skins.reorder-button',
defaultMessage: 'Reorder',
},
finishReorderButton: {
id: 'app.skins.section.saved-skins.finish-reorder-button',
defaultMessage: 'Finish',
},
reorderSkinButton: {
id: 'app.skins.reorder-skin-button',
defaultMessage: 'Reorder skin',
},
})
const props = defineProps<{
@@ -82,6 +102,7 @@ const props = defineProps<{
isSkinSelected: (skin: Skin) => boolean
isSkinActive: (skin: Skin) => boolean
isAddSkinButtonDragActive: boolean
isSavedSkinReorderMode: boolean
}>()
const emit = defineEmits<{
@@ -93,6 +114,8 @@ const emit = defineEmits<{
'add-skin-dragover': [event: DragEvent]
'add-skin-dragleave': [event: DragEvent]
'add-skin-drop': [event: DragEvent]
'reorder-saved-skins': [textureKeys: string[]]
'toggle-saved-skin-reorder': []
}>()
const addSkinButton = useTemplateRef<AddSkinButtonRef>('addSkinButton')
@@ -295,6 +318,13 @@ function getAddSkinButtonElement() {
return button?.getRootElement()
}
function emitSavedSkinOrder(nextSkins: Skin[]) {
emit(
'reorder-saved-skins',
nextSkins.map((skin) => skin.texture_key),
)
}
defineExpose({ getAddSkinButtonElement })
</script>
@@ -316,8 +346,24 @@ defineExpose({ getAddSkinButtonElement })
]"
:style="{ transform: `translateY(${top}px)` }"
>
<div
v-if="section.kind === 'saved'"
class="absolute right-0 z-20"
:class="index === 0 ? 'top-1' : 'top-6'"
>
<ButtonStyled size="small" :color="isSavedSkinReorderMode ? 'brand' : 'standard'">
<button @click.stop="emit('toggle-saved-skin-reorder')">
{{
formatMessage(
isSavedSkinReorderMode ? messages.finishReorderButton : messages.reorderButton,
)
}}
</button>
</ButtonStyled>
</div>
<Accordion
button-class="group flex w-full items-center gap-[6px] bg-transparent m-0 p-0 border-none cursor-pointer text-left"
button-class="group flex w-full items-center gap-[6px] bg-transparent m-0 p-0 pr-24 border-none cursor-pointer text-left"
content-class="pt-2"
:open-by-default="isSectionOpen(section.key)"
@on-open="setSectionOpen(section.key, true)"
@@ -354,61 +400,91 @@ defineExpose({ getAddSkinButtonElement })
</Tooltip>
</template>
<div
<Draggable
v-if="section.kind === 'saved'"
:model-value="section.skins"
item-key="texture_key"
tag="div"
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
handle=".saved-skin-reorder-handle"
ghost-class="saved-skin-sortable-ghost"
chosen-class="saved-skin-sortable-chosen"
drag-class="saved-skin-sortable-drag"
fallback-class="saved-skin-sortable-fallback"
:animation="150"
:disabled="!isSavedSkinReorderMode"
:fallback-on-body="true"
:fallback-tolerance="4"
:force-fallback="true"
@update:model-value="emitSavedSkinOrder"
>
<SkinLikeTextButton
ref="addSkinButton"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
dropzone
:drag-active="isAddSkinButtonDragActive"
@click="emit('add-skin')"
@dragenter="emit('add-skin-dragenter', $event)"
@dragover="emit('add-skin-dragover', $event)"
@dragleave="emit('add-skin-dragleave', $event)"
@drop="emit('add-skin-drop', $event)"
>
<template #icon>
<PlusIcon class="size-8" />
</template>
{{ formatMessage(messages.addSkinButton) }}
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
</SkinLikeTextButton>
<template #header>
<SkinLikeTextButton
ref="addSkinButton"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
dropzone
:drag-active="isAddSkinButtonDragActive"
@click="emit('add-skin')"
@dragenter="emit('add-skin-dragenter', $event)"
@dragover="emit('add-skin-dragover', $event)"
@dragleave="emit('add-skin-dragleave', $event)"
@drop="emit('add-skin-drop', $event)"
>
<template #icon>
<PlusIcon class="size-8" />
</template>
{{ formatMessage(messages.addSkinButton) }}
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
</SkinLikeTextButton>
</template>
<SkinButton
v-for="skin in section.skins"
:key="skinKey(skin, 'saved-skin')"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
@select="emit('select', skin)"
>
<template #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 #item="{ element: skin }">
<SkinButton
:key="skinKey(skin, 'saved-skin')"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
:selectable="!isSavedSkinReorderMode"
@select="emit('select', skin)"
>
<template v-if="isSavedSkinReorderMode" #top-buttons>
<ButtonStyled circular type="transparent">
<button
v-tooltip="formatMessage(messages.reorderSkinButton)"
:aria-label="formatMessage(messages.reorderSkinButton)"
class="saved-skin-reorder-handle pointer-events-auto cursor-grab active:cursor-grabbing"
@click.stop
>
<GripVerticalIcon />
</button>
</ButtonStyled>
</template>
<template v-if="!isSavedSkinReorderMode" #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>
</template>
</Draggable>
<div
v-else
@@ -430,3 +506,21 @@ defineExpose({ getAddSkinButtonElement })
</div>
</div>
</template>
<style scoped>
.saved-skin-sortable-chosen {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.saved-skin-sortable-ghost {
opacity: 0.4;
}
.saved-skin-sortable-drag,
.saved-skin-sortable-fallback {
box-shadow:
0 8px 18px rgba(0, 0, 0, 0.25),
0 2px 8px rgba(0, 0, 0, 0.2);
}
</style>
+6
View File
@@ -142,6 +142,12 @@ export async function remove_custom_skin(skin: Skin): Promise<void> {
})
}
export async function set_custom_skin_order(textureKeys: string[]): Promise<void> {
await invoke('plugin:minecraft-skins|set_custom_skin_order', {
textureKeys,
})
}
export async function save_custom_skin(
skin: Skin,
textureBlob: Uint8Array,
+44 -1
View File
@@ -46,6 +46,7 @@ import {
get_normalized_skin_texture,
normalize_skin_texture,
remove_custom_skin,
set_custom_skin_order,
} from '@/helpers/skins.ts'
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
import { handleSevereError } from '@/store/error'
@@ -269,6 +270,7 @@ let isUnmounted = false
const isDraggingSkinFile = ref(false)
const isAddSkinButtonDragActive = ref(false)
const isSavedSkinReorderMode = ref(false)
const deleteSkinModal = ref()
const skinToDelete = ref<Skin | null>(null)
@@ -480,6 +482,44 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
generateSkinPreviews(skins.value, capes.value)
}
function reorderLocalSavedSkins(textureKeys: string[]) {
const requestedKeys = new Set(textureKeys)
const savedSkinList = skins.value.filter((skin) => skin.source !== 'default')
const savedSkinByKey = new Map(savedSkinList.map((skin) => [skin.texture_key, skin]))
const orderedSavedSkins = [
...textureKeys.map((key) => savedSkinByKey.get(key)).filter((skin): skin is Skin => !!skin),
...savedSkinList.filter((skin) => !requestedKeys.has(skin.texture_key)),
]
let savedSkinIndex = 0
skins.value = skins.value.flatMap((skin) => {
if (skin.source === 'default') {
return [skin]
}
const nextSkin = orderedSavedSkins[savedSkinIndex++]
return nextSkin ? [nextSkin] : []
})
}
async function onSavedSkinsReordered(textureKeys: string[]) {
const previousSkins = skins.value
reorderLocalSavedSkins(textureKeys)
try {
await set_custom_skin_order(textureKeys)
} catch (error) {
skins.value = previousSkins
handleError(error as Error)
await loadSkins()
}
}
function toggleSavedSkinReorderMode() {
isSavedSkinReorderMode.value = !isSavedSkinReorderMode.value
}
function schedulePendingSkinRefresh() {
if (pendingSkinRefreshTimeout !== null) {
window.clearTimeout(pendingSkinRefreshTimeout)
@@ -853,7 +893,7 @@ await loadSkins()
<button
v-else
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="!selectedSkin"
:disabled="!selectedSkin || isSavedSkinReorderMode"
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
>
<EditIcon />
@@ -873,9 +913,12 @@ await loadSkins()
:is-skin-selected="isSkinSelected"
:is-skin-active="isSkinActive"
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
:is-saved-skin-reorder-mode="isSavedSkinReorderMode"
@select="changeSkin"
@edit="(skin, event) => editSkinModal?.show(event, skin)"
@delete="confirmDeleteSkin"
@reorder-saved-skins="onSavedSkinsReordered"
@toggle-saved-skin-reorder="toggleSavedSkinReorderMode"
@add-skin="openAddSkinFileBrowser"
@add-skin-dragenter="onAddSkinDragOver"
@add-skin-dragover="onAddSkinDragOver"
+1
View File
@@ -117,6 +117,7 @@ fn main() {
"equip_skin",
"remove_custom_skin",
"save_custom_skin",
"set_custom_skin_order",
"unequip_skin",
"flush_pending_skin_change",
"flush_pending_skin_change_for_profile",
+9
View File
@@ -14,6 +14,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
equip_skin,
remove_custom_skin,
save_custom_skin,
set_custom_skin_order,
unequip_skin,
flush_pending_skin_change,
flush_pending_skin_change_for_profile,
@@ -91,6 +92,14 @@ pub async fn save_custom_skin(
.await?)
}
/// `invoke('plugin:minecraft-skins|set_custom_skin_order', texture_keys)`
///
/// See also: [minecraft_skins::set_custom_skin_order]
#[tauri::command]
pub async fn set_custom_skin_order(texture_keys: Vec<String>) -> Result<()> {
Ok(minecraft_skins::set_custom_skin_order(texture_keys).await?)
}
/// `invoke('plugin:minecraft-skins|unequip_skin')`
///
/// See also: [minecraft_skins::unequip_skin]