mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
feat: implement drag to reorder skins
This commit is contained in:
@@ -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,
|
||||
MoveIcon,
|
||||
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,10 @@ const messages = defineMessages({
|
||||
id: 'app.skins.delete-button',
|
||||
defaultMessage: 'Delete skin',
|
||||
},
|
||||
reorderSkinButton: {
|
||||
id: 'app.skins.reorder-button',
|
||||
defaultMessage: 'Reorder skin',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -88,6 +100,7 @@ const emit = defineEmits<{
|
||||
select: [skin: Skin]
|
||||
edit: [skin: Skin, event: MouseEvent]
|
||||
delete: [skin: Skin]
|
||||
'reorder-saved-skins': [skins: Skin[]]
|
||||
'add-skin': []
|
||||
'add-skin-dragenter': [event: DragEvent]
|
||||
'add-skin-dragover': [event: DragEvent]
|
||||
@@ -153,6 +166,10 @@ const sections = computed<SkinSection[]>(() => [
|
||||
})),
|
||||
])
|
||||
|
||||
const draggableSavedSkins = ref<Skin[]>([])
|
||||
const isDraggingSavedSkin = ref(false)
|
||||
const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1)
|
||||
|
||||
const sectionLayouts = computed(() => {
|
||||
const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = []
|
||||
let top = 0
|
||||
@@ -209,6 +226,18 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.savedSkins,
|
||||
(nextSkins) => {
|
||||
if (isDraggingSavedSkin.value) {
|
||||
return
|
||||
}
|
||||
|
||||
draggableSavedSkins.value = [...nextSkins]
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
listWidth,
|
||||
(width) => {
|
||||
@@ -257,6 +286,32 @@ function skinKey(skin: Skin, prefix: string) {
|
||||
return `${prefix}-${skin.source}-${skin.texture_key}-${skin.variant}-${skin.cape_id ?? 'no-cape'}`
|
||||
}
|
||||
|
||||
function savedSkinKey(skin: Skin) {
|
||||
return skinKey(skin, 'saved-skin')
|
||||
}
|
||||
|
||||
function doSkinOrdersMatch(firstSkins: Skin[], secondSkins: Skin[]) {
|
||||
return (
|
||||
firstSkins.length === secondSkins.length &&
|
||||
firstSkins.every((skin, index) => savedSkinKey(skin) === savedSkinKey(secondSkins[index]))
|
||||
)
|
||||
}
|
||||
|
||||
function onSavedSkinDragStart() {
|
||||
isDraggingSavedSkin.value = true
|
||||
}
|
||||
|
||||
function onSavedSkinDragEnd() {
|
||||
isDraggingSavedSkin.value = false
|
||||
|
||||
if (doSkinOrdersMatch(draggableSavedSkins.value, props.savedSkins)) {
|
||||
draggableSavedSkins.value = [...props.savedSkins]
|
||||
return
|
||||
}
|
||||
|
||||
emit('reorder-saved-skins', [...draggableSavedSkins.value])
|
||||
}
|
||||
|
||||
function isSectionOpen(key: string) {
|
||||
return openSectionKeys.value.has(key)
|
||||
}
|
||||
@@ -354,61 +409,93 @@ defineExpose({ getAddSkinButtonElement })
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<div
|
||||
<Draggable
|
||||
v-if="section.kind === 'saved'"
|
||||
: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"
|
||||
:item-key="savedSkinKey"
|
||||
handle=".skin-reorder-handle"
|
||||
:animation="250"
|
||||
:swap-threshold="1"
|
||||
:invert-swap="false"
|
||||
:force-fallback="true"
|
||||
:fallback-on-body="true"
|
||||
:fallback-tolerance="4"
|
||||
ghost-class="skin-reorder-ghost"
|
||||
chosen-class="skin-reorder-chosen"
|
||||
drag-class="skin-reorder-drag"
|
||||
fallback-class="skin-reorder-fallback"
|
||||
@start="onSavedSkinDragStart"
|
||||
@end="onSavedSkinDragEnd"
|
||||
>
|
||||
<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">
|
||||
<template #item="{ element: skin }">
|
||||
<div
|
||||
: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"
|
||||
: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>
|
||||
<ButtonStyled v-if="canReorderSavedSkins" circular>
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
|
||||
v-tooltip="formatMessage(messages.reorderSkinButton)"
|
||||
:aria-label="formatMessage(messages.reorderSkinButton)"
|
||||
class="skin-reorder-handle absolute bottom-3 right-3 z-40 cursor-grab active:cursor-grabbing"
|
||||
@click.stop.prevent
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
<MoveIcon />
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
|
||||
<div
|
||||
v-else
|
||||
@@ -442,3 +529,18 @@ defineExpose({ getAddSkinButtonElement })
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.skin-reorder-ghost) {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
:global(.skin-reorder-drag) {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
:global(.skin-reorder-fallback) {
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -142,6 +142,12 @@ export async function remove_custom_skin(skin: Skin): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function reorder_custom_skins(skins: Skin[]): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|reorder_custom_skins', {
|
||||
skins,
|
||||
})
|
||||
}
|
||||
|
||||
export async function save_custom_skin(
|
||||
skin: Skin,
|
||||
textureBlob: Uint8Array,
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
get_normalized_skin_texture,
|
||||
normalize_skin_texture,
|
||||
remove_custom_skin,
|
||||
reorder_custom_skins,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
@@ -129,6 +130,14 @@ const messages = defineMessages({
|
||||
id: 'app.skins.dropped-file-error.text',
|
||||
defaultMessage: 'Failed to read the dropped file.',
|
||||
},
|
||||
reorderSkinErrorTitle: {
|
||||
id: 'app.skins.reorder-error.title',
|
||||
defaultMessage: 'Failed to reorder skins',
|
||||
},
|
||||
reorderSkinErrorText: {
|
||||
id: 'app.skins.reorder-error.text',
|
||||
defaultMessage: 'Your skin order could not be saved.',
|
||||
},
|
||||
deleteSkinTitle: {
|
||||
id: 'app.skins.delete-modal.title',
|
||||
defaultMessage: 'Are you sure you want to delete this skin?',
|
||||
@@ -423,6 +432,19 @@ function setLocallyEquippedSkin(skinToApply: Skin) {
|
||||
void accountsCard.value?.setEquippedSkin(originalSelectedSkin.value)
|
||||
}
|
||||
|
||||
function insertLocalSkin(savedSkin: Skin) {
|
||||
const firstNonCustomSkinIndex = skins.value.findIndex((skin) => skin.source !== 'custom')
|
||||
|
||||
if (firstNonCustomSkinIndex === -1) {
|
||||
skins.value = [...skins.value, savedSkin]
|
||||
return
|
||||
}
|
||||
|
||||
const nextSkins = [...skins.value]
|
||||
nextSkins.splice(firstNonCustomSkinIndex, 0, savedSkin)
|
||||
skins.value = nextSkins
|
||||
}
|
||||
|
||||
function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin) {
|
||||
let foundSkin = false
|
||||
const replacesSelectedSkin =
|
||||
@@ -451,7 +473,7 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
|
||||
})
|
||||
|
||||
if (!foundSkin) {
|
||||
skins.value.unshift({
|
||||
insertLocalSkin({
|
||||
...savedSkin,
|
||||
is_equipped: applied || savedSkin.is_equipped,
|
||||
})
|
||||
@@ -480,6 +502,33 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
|
||||
generateSkinPreviews(skins.value, capes.value)
|
||||
}
|
||||
|
||||
async function reorderSavedSkins(orderedSkins: Skin[]) {
|
||||
const previousSkins = skins.value
|
||||
const orderedTextureKeys = orderedSkins.map((skin) => skin.texture_key)
|
||||
const orderedTextureKeySet = new Set(orderedTextureKeys)
|
||||
const remainingSavedSkins = previousSkins.filter(
|
||||
(skin) => skin.source !== 'default' && !orderedTextureKeySet.has(skin.texture_key),
|
||||
)
|
||||
const defaultSkins = previousSkins.filter((skin) => skin.source === 'default')
|
||||
const nextSavedSkins = [...orderedSkins, ...remainingSavedSkins]
|
||||
|
||||
skins.value = [...nextSavedSkins, ...defaultSkins]
|
||||
generateSkinPreviews(skins.value, capes.value)
|
||||
|
||||
try {
|
||||
await reorder_custom_skins(nextSavedSkins)
|
||||
} catch (error) {
|
||||
skins.value = previousSkins
|
||||
generateSkinPreviews(skins.value, capes.value)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.reorderSkinErrorTitle),
|
||||
text: error instanceof Error ? error.message : formatMessage(messages.reorderSkinErrorText),
|
||||
})
|
||||
await loadSkins()
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePendingSkinRefresh() {
|
||||
if (pendingSkinRefreshTimeout !== null) {
|
||||
window.clearTimeout(pendingSkinRefreshTimeout)
|
||||
@@ -876,6 +925,7 @@ await loadSkins()
|
||||
@select="changeSkin"
|
||||
@edit="(skin, event) => editSkinModal?.show(event, skin)"
|
||||
@delete="confirmDeleteSkin"
|
||||
@reorder-saved-skins="reorderSavedSkins"
|
||||
@add-skin="openAddSkinFileBrowser"
|
||||
@add-skin-dragenter="onAddSkinDragOver"
|
||||
@add-skin-dragover="onAddSkinDragOver"
|
||||
|
||||
Reference in New Issue
Block a user