feat: drag and drop skins to reorder (#6357)

* feat: drag and drop skins to reorder

* feat: implement drag to reorder skins

* fix: ci

* remove: backend implementation

* regenerate sqlx

* fix: remove v-if selectable

* feat: remove drag handle

* refactor: pnpm prepr

* cargo fmt

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

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-06-11 06:22:38 -06:00
committed by GitHub
parent d2a66bb2b0
commit c1780eef7d
29 changed files with 713 additions and 162 deletions
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ? AND display_order >= ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "11875ae76d8a61099dff08e9792fd6f231ecffd45315ed2b76253d02bbd531ff"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
"describe": {
"columns": [
{
"name": "display_order",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "18f04a0f6c262995b5f1eee10c2c5a396443ead9a9e295f6eea0986e40d65449"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "3538a56e37f456ca57998bd4753da27bff30801646436358acf57eb4cdfa25ad"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY rowid ASC LIMIT ? OFFSET ?",
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY display_order ASC, rowid ASC LIMIT ? OFFSET ?",
"describe": {
"columns": [
{
@@ -17,6 +17,11 @@
"name": "cape_id: Hyphenated",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "display_order",
"ordinal": 3,
"type_info": "Integer"
}
],
"parameters": {
@@ -25,8 +30,9 @@
"nullable": [
false,
false,
true
true,
false
]
},
"hash": "aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac"
"hash": "57df2984dd65b408473ed1ae86b0215f46946f9b9043d4b037e3a4d5b27e0373"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT texture_key FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY display_order ASC, rowid ASC",
"describe": {
"columns": [
{
"name": "texture_key",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "69c5a93676809ab6480fc41a2e6e34b16057db8b0eab08a9d8b8dce961c6f81c"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id, display_order) VALUES (?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "9a9b8b5c0b646b841b73461980b37ad3b03ecf99d1484c402694799dc08271f5"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
"describe": {
"columns": [
{
@@ -17,6 +17,11 @@
"name": "cape_id: Hyphenated",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "display_order",
"ordinal": 3,
"type_info": "Integer"
}
],
"parameters": {
@@ -25,8 +30,9 @@
"nullable": [
false,
false,
true
true,
false
]
},
"hash": "a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d"
"hash": "a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ?",
"describe": {
"columns": [
{
"name": "display_order!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "bab7f687a8397975747cfe194a0e2cbc2e701584d8b3394a1aa686e3ff4d47f5"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE custom_minecraft_skins SET display_order = ? WHERE minecraft_user_uuid = ? AND texture_key = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "d68c41fb2cb182aa8fc23422c55bd5b728ba93d9e3bb6f3db57ac1c83574d508"
}
@@ -0,0 +1,13 @@
ALTER TABLE custom_minecraft_skins
ADD COLUMN display_order INTEGER NOT NULL DEFAULT 0;
UPDATE custom_minecraft_skins
SET display_order = (
SELECT COUNT(*)
FROM custom_minecraft_skins AS previous
WHERE previous.minecraft_user_uuid = custom_minecraft_skins.minecraft_user_uuid
AND previous.rowid <= custom_minecraft_skins.rowid
) - 1;
CREATE INDEX custom_minecraft_skins_user_display_order
ON custom_minecraft_skins (minecraft_user_uuid, display_order);
+80 -2
View File
@@ -65,7 +65,9 @@ use crate::{
ErrorKind, State,
state::{
MinecraftCharacterExpressionState, MinecraftProfile,
minecraft_skins::{CustomMinecraftSkin, mojang_api},
minecraft_skins::{
CustomMinecraftSkin, CustomMinecraftSkinInsertPosition, mojang_api,
},
},
};
@@ -408,6 +410,7 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
&texture_blob,
custom_skin.variant,
custom_skin.cape_id,
CustomMinecraftSkinInsertPosition::Bottom,
&state.pool,
)
.await?;
@@ -453,7 +456,6 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
});
}
custom_skins.sort_by(|a, b| a.texture.as_str().cmp(b.texture.as_str()));
available_skins.extend(custom_skins);
for default_skin in assets::DEFAULT_SKINS.iter() {
@@ -534,6 +536,7 @@ pub async fn add_and_equip_custom_skin(
&texture_blob,
variant,
cape_id,
CustomMinecraftSkinInsertPosition::Top,
&state.pool,
)
.await?;
@@ -606,6 +609,21 @@ async fn add_and_equip_custom_skin_now(
let equipped_skin = profile.current_skin()?;
let equipped_skin_texture_key = equipped_skin.texture_key();
let equipped_skin_variant = equipped_skin.variant;
let insert_position = if local_texture_key
!= equipped_skin_texture_key.as_ref()
{
CustomMinecraftSkin::get_by_texture(
profile.id,
local_texture_key,
&state.pool,
)
.await?
.map_or(CustomMinecraftSkinInsertPosition::Top, |skin| {
CustomMinecraftSkinInsertPosition::At(skin.display_order)
})
} else {
CustomMinecraftSkinInsertPosition::Top
};
let persistence_result = if cape_id.is_none()
&& is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant)
@@ -614,6 +632,7 @@ async fn add_and_equip_custom_skin_now(
texture_key: equipped_skin_texture_key.to_string(),
variant: equipped_skin_variant,
cape_id: None,
display_order: 0,
}
.remove(profile.id, &state.pool)
.await
@@ -624,6 +643,7 @@ async fn add_and_equip_custom_skin_now(
&texture_blob,
variant,
cape_id,
insert_position,
&state.pool,
)
.await
@@ -639,6 +659,7 @@ async fn add_and_equip_custom_skin_now(
texture_key: local_texture_key.to_string(),
variant,
cape_id,
display_order: 0,
}
.remove(profile.id, &state.pool)
.await?;
@@ -741,6 +762,22 @@ async fn persist_equipped_skin(
let equipped_skin_variant = equipped_skin.variant;
let texture_key_changed =
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()
&& is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant)
@@ -749,6 +786,7 @@ async fn persist_equipped_skin(
texture_key: equipped_skin_texture_key.to_string(),
variant: equipped_skin_variant,
cape_id: None,
display_order: 0,
}
.remove(profile.id, &state.pool)
.await?;
@@ -760,6 +798,7 @@ async fn persist_equipped_skin(
texture_blob,
equipped_skin_variant,
skin.cape_id,
insert_position,
&state.pool,
)
.await?;
@@ -770,6 +809,7 @@ async fn persist_equipped_skin(
texture_key: skin.texture_key.to_string(),
variant: skin.variant,
cape_id: skin.cape_id,
display_order: 0,
}
.remove(profile.id, &state.pool)
.await?;
@@ -796,6 +836,7 @@ pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> {
texture_key: skin.texture_key.to_string(),
variant: skin.variant,
cape_id: skin.cape_id,
display_order: 0,
}
.remove(selected_credentials.offline_profile.id, &state.pool)
.await?;
@@ -809,6 +850,25 @@ pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> {
Ok(())
}
/// Persists the custom saved skin order for the currently selected Minecraft profile.
#[tracing::instrument]
pub async fn set_custom_skin_order(
texture_keys: Vec<String>,
) -> crate::Result<()> {
let state = State::get().await?;
let selected_credentials = Credentials::get_default_credential(&state.pool)
.await?
.ok_or(ErrorKind::NoCredentialsError)?;
CustomMinecraftSkin::set_order(
selected_credentials.offline_profile.id,
&texture_keys,
&state.pool,
)
.await
}
/// Adds or updates a saved skin locally without applying it to Mojang.
///
/// This is used by the skin editor. If the edited skin is currently equipped, the caller should
@@ -839,12 +899,26 @@ pub async fn save_custom_skin(
Arc::clone(&skin.texture_key)
};
let cape_id = cape.map(|cape| cape.id);
let insert_position = if replace_texture && old_texture_key != texture_key {
CustomMinecraftSkin::get_by_texture(
selected_credentials.offline_profile.id,
&old_texture_key,
&state.pool,
)
.await?
.map_or(CustomMinecraftSkinInsertPosition::Bottom, |skin| {
CustomMinecraftSkinInsertPosition::At(skin.display_order)
})
} else {
CustomMinecraftSkinInsertPosition::Bottom
};
if cape_id.is_none() && is_bundled_skin(&texture_key, variant) {
CustomMinecraftSkin {
texture_key: texture_key.to_string(),
variant,
cape_id: None,
display_order: 0,
}
.remove(selected_credentials.offline_profile.id, &state.pool)
.await?;
@@ -855,6 +929,7 @@ pub async fn save_custom_skin(
&texture_blob,
variant,
cape_id,
insert_position,
&state.pool,
)
.await?;
@@ -865,6 +940,7 @@ pub async fn save_custom_skin(
texture_key: old_texture_key.to_string(),
variant: skin.variant,
cape_id: skin.cape_id,
display_order: 0,
}
.remove(selected_credentials.offline_profile.id, &state.pool)
.await?;
@@ -1210,6 +1286,7 @@ async fn preserve_current_profile_skin(
&texture,
current_skin.variant,
current_cape_id,
CustomMinecraftSkinInsertPosition::Bottom,
&state.pool,
)
.await?;
@@ -1231,6 +1308,7 @@ async fn preserve_current_profile_skin(
&texture,
current_skin.variant,
current_cape_id,
CustomMinecraftSkinInsertPosition::Bottom,
&state.pool,
)
.await?;
@@ -1,3 +1,5 @@
use std::collections::HashSet;
use futures::{Stream, StreamExt, stream};
use uuid::{Uuid, fmt::Hyphenated};
@@ -22,12 +24,22 @@ pub struct CustomMinecraftSkin {
///
/// If `None`, the skin is saved without a cape.
pub cape_id: Option<Uuid>,
/// The saved skin display order within this player's saved skins.
pub display_order: i64,
}
#[derive(Debug, Clone, Copy)]
pub enum CustomMinecraftSkinInsertPosition {
Top,
Bottom,
At(i64),
}
struct CustomMinecraftSkinRow {
texture_key: String,
variant: MinecraftSkinVariant,
cape_id: Option<Hyphenated>,
display_order: i64,
}
impl CustomMinecraftSkin {
@@ -37,6 +49,7 @@ impl CustomMinecraftSkin {
texture: &[u8],
variant: MinecraftSkinVariant,
cape_id: Option<Uuid>,
insert_position: CustomMinecraftSkinInsertPosition,
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
@@ -44,6 +57,51 @@ impl CustomMinecraftSkin {
let mut transaction = db.begin().await?;
let existing_order = sqlx::query_scalar!(
"SELECT display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
minecraft_user_id,
texture_key
)
.fetch_optional(&mut *transaction)
.await?;
let display_order = match existing_order {
Some(display_order) => display_order,
None => match insert_position {
CustomMinecraftSkinInsertPosition::Top => {
sqlx::query!(
"UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ?",
minecraft_user_id
)
.execute(&mut *transaction)
.await?;
0
}
CustomMinecraftSkinInsertPosition::Bottom => {
sqlx::query_scalar!(
"SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' \
FROM custom_minecraft_skins WHERE minecraft_user_uuid = ?",
minecraft_user_id
)
.fetch_one(&mut *transaction)
.await?
}
CustomMinecraftSkinInsertPosition::At(display_order) => {
sqlx::query!(
"UPDATE custom_minecraft_skins SET display_order = display_order + 1 \
WHERE minecraft_user_uuid = ? AND display_order >= ?",
minecraft_user_id,
display_order
)
.execute(&mut *transaction)
.await?;
display_order
}
},
};
sqlx::query!(
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
minecraft_user_id,
@@ -57,11 +115,11 @@ impl CustomMinecraftSkin {
texture_key, texture
)
.execute(&mut *transaction)
.await?;
.await?;
sqlx::query!(
"INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
minecraft_user_id, texture_key, variant, cape_id
"INSERT INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id, display_order) VALUES (?, ?, ?, ?, ?)",
minecraft_user_id, texture_key, variant, cape_id, display_order
)
.execute(&mut *transaction)
.await?;
@@ -80,7 +138,7 @@ impl CustomMinecraftSkin {
sqlx::query_as!(
CustomMinecraftSkinRow,
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order \
FROM custom_minecraft_skins \
WHERE minecraft_user_uuid = ? AND texture_key = ?",
minecraft_user_id,
@@ -93,6 +151,7 @@ impl CustomMinecraftSkin {
texture_key: row.texture_key,
variant: row.variant,
cape_id: row.cape_id.map(Uuid::from),
display_order: row.display_order,
})
})
.transpose()
@@ -107,10 +166,10 @@ impl CustomMinecraftSkin {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
Ok(stream::iter(sqlx::query!(
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order \
FROM custom_minecraft_skins \
WHERE minecraft_user_uuid = ? \
ORDER BY rowid ASC \
ORDER BY display_order ASC, rowid ASC \
LIMIT ? OFFSET ?",
minecraft_user_id, count, offset
)
@@ -120,6 +179,7 @@ impl CustomMinecraftSkin {
texture_key: row.texture_key,
variant: row.variant,
cape_id: row.cape_id.map(Uuid::from),
display_order: row.display_order,
}))
}
@@ -161,4 +221,62 @@ impl CustomMinecraftSkin {
Ok(())
}
pub async fn set_order(
minecraft_user_id: Uuid,
texture_keys: &[String],
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
let mut transaction = db.begin().await?;
let existing_rows = sqlx::query!(
"SELECT texture_key FROM custom_minecraft_skins \
WHERE minecraft_user_uuid = ? \
ORDER BY display_order ASC, rowid ASC",
minecraft_user_id
)
.fetch_all(&mut *transaction)
.await?;
let existing_keys = existing_rows
.iter()
.map(|row| row.texture_key.as_str())
.collect::<HashSet<_>>();
let mut seen_keys = HashSet::new();
let mut ordered_keys = Vec::with_capacity(existing_rows.len());
for texture_key in texture_keys {
if seen_keys.insert(texture_key.as_str())
&& existing_keys.contains(texture_key.as_str())
{
ordered_keys.push(texture_key.as_str());
}
}
for row in &existing_rows {
if seen_keys.insert(row.texture_key.as_str()) {
ordered_keys.push(row.texture_key.as_str());
}
}
for (display_order, texture_key) in ordered_keys.into_iter().enumerate()
{
let display_order = display_order as i64;
sqlx::query!(
"UPDATE custom_minecraft_skins SET display_order = ? \
WHERE minecraft_user_uuid = ? AND texture_key = ?",
display_order,
minecraft_user_id,
texture_key
)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(())
}
}
+4
View File
@@ -135,6 +135,7 @@ import _GitGraphIcon from './icons/git-graph.svg?component'
import _GlassesIcon from './icons/glasses.svg?component'
import _GlobeIcon from './icons/globe.svg?component'
import _GridIcon from './icons/grid.svg?component'
import _GripVerticalIcon from './icons/grip-vertical.svg?component'
import _HamburgerIcon from './icons/hamburger.svg?component'
import _HammerIcon from './icons/hammer.svg?component'
import _HandHelpingIcon from './icons/hand-helping.svg?component'
@@ -189,6 +190,7 @@ import _MonitorSmartphoneIcon from './icons/monitor-smartphone.svg?component'
import _MoonIcon from './icons/moon.svg?component'
import _MoreHorizontalIcon from './icons/more-horizontal.svg?component'
import _MoreVerticalIcon from './icons/more-vertical.svg?component'
import _MoveIcon from './icons/move.svg?component'
import _NewspaperIcon from './icons/newspaper.svg?component'
import _NoSignalIcon from './icons/no-signal.svg?component'
import _NotepadTextIcon from './icons/notepad-text.svg?component'
@@ -555,6 +557,7 @@ export const GitGraphIcon = _GitGraphIcon
export const GlassesIcon = _GlassesIcon
export const GlobeIcon = _GlobeIcon
export const GridIcon = _GridIcon
export const GripVerticalIcon = _GripVerticalIcon
export const HamburgerIcon = _HamburgerIcon
export const HammerIcon = _HammerIcon
export const HandHelpingIcon = _HandHelpingIcon
@@ -609,6 +612,7 @@ export const MonitorSmartphoneIcon = _MonitorSmartphoneIcon
export const MoonIcon = _MoonIcon
export const MoreHorizontalIcon = _MoreHorizontalIcon
export const MoreVerticalIcon = _MoreVerticalIcon
export const MoveIcon = _MoveIcon
export const NewspaperIcon = _NewspaperIcon
export const NoSignalIcon = _NoSignalIcon
export const NotepadTextIcon = _NotepadTextIcon
+20
View File
@@ -0,0 +1,20 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-grip-vertical"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="9" cy="12" r="1" />
<circle cx="9" cy="5" r="1" />
<circle cx="9" cy="19" r="1" />
<circle cx="15" cy="12" r="1" />
<circle cx="15" cy="5" r="1" />
<circle cx="15" cy="19" r="1" />
</svg>

After

Width:  |  Height:  |  Size: 498 B

+20
View File
@@ -0,0 +1,20 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-move"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2v20" />
<path d="m15 19-3 3-3-3" />
<path d="m19 9 3 3-3 3" />
<path d="M2 12h20" />
<path d="m5 9-3 3 3 3" />
<path d="m9 5 3-3 3 3" />
</svg>

After

Width:  |  Height:  |  Size: 447 B

+22 -45
View File
@@ -9,28 +9,27 @@ const emit = defineEmits<{
const props = withDefaults(
defineProps<{
forwardImageSrc?: string
backwardImageSrc?: string
selected: boolean
active?: boolean
tooltip?: string
disabled?: boolean
isDragging?: boolean
}>(),
{
forwardImageSrc: undefined,
backwardImageSrc: undefined,
active: false,
tooltip: undefined,
disabled: false,
isDragging: false,
},
)
const imagesLoaded = ref({
forward: false,
backward: false,
})
function onImageLoad(type: 'forward' | 'backward') {
imagesLoaded.value[type] = true
function onImageLoad() {
imagesLoaded.value.forward = true
}
watch(
@@ -39,13 +38,6 @@ watch(
imagesLoaded.value.forward = false
},
)
watch(
() => props.backwardImageSrc,
() => {
imagesLoaded.value.backward = false
},
)
</script>
<template>
@@ -58,9 +50,17 @@ watch(
{
'skin-button--with-actions': $slots['overlay-buttons'] && !disabled,
'skin-button--disabled': disabled,
'skin-button--dragging': isDragging,
},
]"
>
<span
v-if="$slots['top-buttons']"
class="pointer-events-none absolute right-3 top-3 z-30 flex items-center gap-1"
>
<slot name="top-buttons" />
</span>
<button
class="absolute inset-0 z-10 cursor-pointer border-none bg-transparent p-0 focus-visible:outline-none"
:aria-label="tooltip ? `Select ${tooltip}` : 'Select skin'"
@@ -70,19 +70,16 @@ watch(
></button>
<span
v-if="active && !selected"
v-if="active && !selected && !$slots['top-buttons']"
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>
<div
v-if="!(imagesLoaded.forward && imagesLoaded.backward)"
class="skeleton-loader h-full w-full"
>
<div v-if="!imagesLoaded.forward" class="skeleton-loader h-full w-full">
<div class="skeleton absolute inset-0 aspect-[5/7]"></div>
</div>
<span
v-show="imagesLoaded.forward && imagesLoaded.backward"
v-show="imagesLoaded.forward"
:key="`${selected}-${active}`"
:class="[
'skin-button__image-parent pointer-events-none relative z-0 mb-[1.5px] grid place-items-stretch with-shadow',
@@ -93,14 +90,7 @@ watch(
:src="forwardImageSrc"
class="skin-button__image-facing col-start-1 row-start-1 h-full w-full object-contain"
height="504"
@load="onImageLoad('forward')"
/>
<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')"
@load="onImageLoad"
/>
</span>
@@ -195,30 +185,17 @@ watch(
cursor: not-allowed;
}
.skin-button--dragging {
pointer-events: none;
}
.skin-button__image-parent {
width: 100%;
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 {
transform: rotateY(180deg) 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);
.skin-button__image-facing {
transform: translateZ(0);
}
.with-shadow img {
@@ -21,25 +21,11 @@ const frontImage = `data:image/svg+xml,${encodeURIComponent(`
</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 = {
title: 'Skin/SkinButton',
component: SkinButton,
args: {
forwardImageSrc: frontImage,
backwardImageSrc: backImage,
selected: false,
active: false,
tooltip: 'Steve',