mirror of
https://github.com/koel/koel
synced 2024-11-28 06:50:27 +00:00
feat: revamp drag-n-drop functionalities
This commit is contained in:
parent
52dd323c96
commit
e8a1cdece7
27 changed files with 318 additions and 259 deletions
|
@ -2,7 +2,7 @@ import isMobile from 'ismobilejs'
|
|||
import { isObject, mergeWith } from 'lodash'
|
||||
import { cleanup, render, RenderOptions } from '@testing-library/vue'
|
||||
import { afterEach, beforeEach, vi } from 'vitest'
|
||||
import { clickaway, droppable, focus } from '@/directives'
|
||||
import { clickaway, focus } from '@/directives'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { commonStore, userStore } from '@/stores'
|
||||
import factory from '@/__tests__/factory'
|
||||
|
@ -81,8 +81,7 @@ export default abstract class UnitTestCase {
|
|||
global: {
|
||||
directives: {
|
||||
'koel-clickaway': clickaway,
|
||||
'koel-focus': focus,
|
||||
'koel-droppable': droppable
|
||||
'koel-focus': focus
|
||||
},
|
||||
components: {
|
||||
icon: this.stub('icon')
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'plyr/dist/plyr.js'
|
||||
import { createApp } from 'vue'
|
||||
import { clickaway, droppable, focus } from '@/directives'
|
||||
import { clickaway, focus } from '@/directives'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import App from './App.vue'
|
||||
|
||||
|
@ -8,7 +8,6 @@ createApp(App)
|
|||
.component('icon', FontAwesomeIcon)
|
||||
.directive('koel-focus', focus)
|
||||
.directive('koel-clickaway', clickaway)
|
||||
.directive('koel-droppable', droppable)
|
||||
/**
|
||||
* For Ancelot, the ancient cross of war
|
||||
* for the holy town of Gods
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
draggable="true"
|
||||
tabindex="0"
|
||||
@dblclick="shuffle"
|
||||
@dragstart="dragStart"
|
||||
@dragstart="onDragStart"
|
||||
@contextmenu.prevent="requestContextMenu"
|
||||
>
|
||||
<AlbumThumbnail :entity="album"/>
|
||||
|
@ -56,12 +56,15 @@
|
|||
<script lang="ts" setup>
|
||||
import { faDownload, faRandom } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, toRef, toRefs } from 'vue'
|
||||
import { eventBus, pluralize, secondsToHis, startDragging } from '@/utils'
|
||||
import { eventBus, pluralize, secondsToHis } from '@/utils'
|
||||
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
|
||||
import { downloadService, playbackService } from '@/services'
|
||||
import { useDraggable } from '@/composables'
|
||||
|
||||
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
|
||||
const { startDragging } = useDraggable('album')
|
||||
|
||||
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||
const { album, layout } = toRefs(props)
|
||||
|
||||
|
@ -76,7 +79,7 @@ const shuffle = async () => {
|
|||
}
|
||||
|
||||
const download = () => downloadService.fromAlbum(album.value)
|
||||
const dragStart = (event: DragEvent) => startDragging(event, album.value, 'Album')
|
||||
const onDragStart = (event: DragEvent) => startDragging(event, album.value)
|
||||
const requestContextMenu = (event: MouseEvent) => eventBus.emit('ALBUM_CONTEXT_MENU_REQUESTED', event, album.value)
|
||||
</script>
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
draggable="true"
|
||||
tabindex="0"
|
||||
@dblclick="shuffle"
|
||||
@dragstart="dragStart"
|
||||
@dragstart="onDragStart"
|
||||
@contextmenu.prevent="requestContextMenu"
|
||||
>
|
||||
<ArtistThumbnail :entity="artist"/>
|
||||
|
@ -56,11 +56,15 @@
|
|||
<script lang="ts" setup>
|
||||
import { faDownload, faRandom } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, toRef, toRefs } from 'vue'
|
||||
import { eventBus, pluralize, startDragging } from '@/utils'
|
||||
import { eventBus, pluralize } from '@/utils'
|
||||
import { artistStore, commonStore, songStore } from '@/stores'
|
||||
import { downloadService, playbackService } from '@/services'
|
||||
import { useDraggable } from '@/composables'
|
||||
|
||||
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
|
||||
const { startDragging } = useDraggable('artist')
|
||||
|
||||
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||
const { artist, layout } = toRefs(props)
|
||||
|
||||
|
@ -73,7 +77,7 @@ const shuffle = async () => {
|
|||
}
|
||||
|
||||
const download = () => downloadService.fromArtist(artist.value)
|
||||
const dragStart = (event: DragEvent) => startDragging(event, artist.value, 'Artist')
|
||||
const onDragStart = (event: DragEvent) => startDragging(event, artist.value)
|
||||
const requestContextMenu = (event: MouseEvent) => eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', event, artist.value)
|
||||
</script>
|
||||
|
||||
|
|
|
@ -10,8 +10,8 @@
|
|||
Home
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a v-koel-droppable="handleDrop" :class="['queue', currentView === 'Queue' ? 'active' : '']" href="#!/queue">
|
||||
<li ref="queueMenuItemEl" @dragleave="onQueueDragLeave" @dragover="onQueueDragOver" @drop="onQueueDrop">
|
||||
<a :class="['queue', currentView === 'Queue' ? 'active' : '']" href="#!/queue">
|
||||
<icon :icon="faListOl" fixed-width/>
|
||||
Current Queue
|
||||
</a>
|
||||
|
@ -86,19 +86,37 @@ import {
|
|||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { faYoutube } from '@fortawesome/free-brands-svg-icons'
|
||||
import { ref } from 'vue'
|
||||
import { eventBus, resolveSongsFromDragEvent } from '@/utils'
|
||||
import { eventBus } from '@/utils'
|
||||
import { queueStore } from '@/stores'
|
||||
import { useAuthorization, useThirdPartyServices } from '@/composables'
|
||||
import { useAuthorization, useDroppable, useThirdPartyServices } from '@/composables'
|
||||
|
||||
import PlaylistList from '@/components/playlist/PlaylistSidebarList.vue'
|
||||
|
||||
const showing = ref(!isMobile.phone)
|
||||
const currentView = ref<MainViewName>('Home')
|
||||
const queueMenuItemEl = ref<HTMLLIElement>()
|
||||
|
||||
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist', 'playlist'])
|
||||
const { useYouTube } = useThirdPartyServices()
|
||||
const { isAdmin } = useAuthorization()
|
||||
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
const songs = await resolveSongsFromDragEvent(event)
|
||||
const onQueueDragOver = (event: DragEvent) => {
|
||||
if (!acceptsDrop(event)) return false
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
queueMenuItemEl.value!.classList.add('droppable')
|
||||
}
|
||||
|
||||
const onQueueDragLeave = () => queueMenuItemEl.value!.classList.remove('droppable')
|
||||
|
||||
const onQueueDrop = async (event: DragEvent) => {
|
||||
queueMenuItemEl.value!.classList.remove('droppable')
|
||||
|
||||
if (!acceptsDrop(event)) return false
|
||||
|
||||
event.preventDefault()
|
||||
const songs = await resolveDroppedSongs(event) || []
|
||||
songs.length && queueStore.queue(songs)
|
||||
|
||||
return false
|
||||
|
@ -137,13 +155,10 @@ nav {
|
|||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
a.droppable {
|
||||
transform: scale(1.2);
|
||||
transition: .3s;
|
||||
transform-origin: center left;
|
||||
|
||||
color: var(--color-text-primary);
|
||||
background-color: rgba(0, 0, 0, .3);
|
||||
.droppable {
|
||||
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||
border-radius: 4px;
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.queue > span {
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
<li
|
||||
ref="el"
|
||||
class="playlist-folder"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@dragleave="onDragLeave"
|
||||
@dragover="onDragOver"
|
||||
@drop.prevent="onDrop"
|
||||
@drop="onDrop"
|
||||
tabindex="0"
|
||||
>
|
||||
<a @click.prevent="toggle" @contextmenu.prevent="onContextMenu">
|
||||
|
@ -36,7 +36,8 @@
|
|||
import { faFolder, faFolderOpen } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, defineAsyncComponent, ref, toRefs } from 'vue'
|
||||
import { playlistFolderStore, playlistStore } from '@/stores'
|
||||
import { eventBus, logger } from '@/utils'
|
||||
import { eventBus } from '@/utils'
|
||||
import { useDroppable } from '@/composables'
|
||||
|
||||
const PlaylistSidebarItem = defineAsyncComponent(() => import('@/components/playlist/PlaylistSidebarItem.vue'))
|
||||
|
||||
|
@ -49,41 +50,47 @@ const opened = ref(false)
|
|||
|
||||
const playlistsInFolder = computed(() => playlistStore.byFolder(folder.value))
|
||||
|
||||
const { acceptsDrop, resolveDroppedValue } = useDroppable(['playlist'])
|
||||
|
||||
const toggle = () => (opened.value = !opened.value)
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
if (!event.dataTransfer?.types.includes('playlist')) return false
|
||||
if (!acceptsDrop(event)) return false
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
el.value!.classList.add('drag-over')
|
||||
opened.value || (opened.value = true)
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
el.value!.classList.add('droppable')
|
||||
opened.value = true
|
||||
}
|
||||
|
||||
const onDragLeave = () => el.value!.classList.remove('drag-over')
|
||||
const onDragLeave = () => el.value!.classList.remove('droppable')
|
||||
|
||||
const onDrop = async (event: DragEvent) => {
|
||||
el.value!.classList.remove('drag-over')
|
||||
const playlist = getPlaylistFromDropEvent(event)
|
||||
if (!acceptsDrop(event)) return false
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
el.value!.classList.remove('droppable')
|
||||
const playlist = await resolveDroppedValue<Playlist>(event)
|
||||
if (!playlist || playlist.folder_id === folder.value.id) return
|
||||
|
||||
await playlistFolderStore.addPlaylistToFolder(folder.value, playlist)
|
||||
}
|
||||
|
||||
const onDragLeaveHatch = () => hatch.value!.classList.remove('drag-over')
|
||||
const onDragLeaveHatch = () => hatch.value!.classList.remove('droppable')
|
||||
|
||||
const onDragOverHatch = (event: DragEvent) => {
|
||||
if (!event.dataTransfer?.types.includes('playlist')) return false
|
||||
if (!acceptsDrop(event)) return false
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
hatch.value!.classList.add('drag-over')
|
||||
hatch.value!.classList.add('droppable')
|
||||
}
|
||||
|
||||
const onDropOnHatch = async (event: DragEvent) => {
|
||||
hatch.value!.classList.remove('drag-over')
|
||||
el.value!.classList.remove('drag-over')
|
||||
const playlist = getPlaylistFromDropEvent(event)!
|
||||
hatch.value!.classList.remove('droppable')
|
||||
el.value!.classList.remove('droppable')
|
||||
const playlist = (await resolveDroppedValue<Playlist>(event))!
|
||||
|
||||
// if the playlist isn't in the folder, don't do anything. The folder will handle the drop.
|
||||
if (playlist.folder_id !== folder.value.id) return
|
||||
|
@ -93,15 +100,6 @@ const onDropOnHatch = async (event: DragEvent) => {
|
|||
await playlistFolderStore.removePlaylistFromFolder(folder.value, playlist)
|
||||
}
|
||||
|
||||
const getPlaylistFromDropEvent = (event: DragEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.dataTransfer?.getData('application/x-koel.text+plain')!)
|
||||
return playlistStore.byId(data.value as number)
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const onContextMenu = event => eventBus.emit('PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED', event, folder.value)
|
||||
</script>
|
||||
|
||||
|
@ -109,7 +107,7 @@ const onContextMenu = event => eventBus.emit('PLAYLIST_FOLDER_CONTEXT_MENU_REQUE
|
|||
li.playlist-folder {
|
||||
position: relative;
|
||||
|
||||
&.drag-over {
|
||||
&.droppable {
|
||||
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||
border-radius: 4px;
|
||||
cursor: copy;
|
||||
|
@ -121,7 +119,7 @@ li.playlist-folder {
|
|||
width: 100%;
|
||||
height: .5rem;
|
||||
|
||||
&.drag-over {
|
||||
&.droppable {
|
||||
border-bottom: 3px solid var(--color-highlight);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
<template>
|
||||
<li
|
||||
ref="el"
|
||||
:class="['playlist', type, playlist.is_smart ? 'smart' : '']"
|
||||
data-testid="playlist-sidebar-item"
|
||||
draggable="true"
|
||||
@dragstart="dragStart"
|
||||
@dragleave="onDragLeave"
|
||||
@dragover="onDragOver"
|
||||
@dragstart="onDragStart"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<a
|
||||
v-if="contentEditable"
|
||||
v-koel-droppable="handleDrop"
|
||||
:class="{ active }"
|
||||
:href="url"
|
||||
@contextmenu.prevent="openContextMenu"
|
||||
>
|
||||
<icon v-if="type === 'favorites'" :icon="faHeart" class="text-maroon" fixed-width/>
|
||||
<icon v-else :icon="faMusic" :mask="faFile" transform="shrink-7 down-2" fixed-width/>
|
||||
{{ playlist.name }}
|
||||
</a>
|
||||
|
||||
<a v-else :class="{ active }" :href="url" @contextmenu.prevent="openContextMenu">
|
||||
<a :class="{ active }" :href="url" @contextmenu.prevent="onContextMenu">
|
||||
<icon v-if="type === 'recently-played'" :icon="faClockRotateLeft" class="text-green" fixed-width/>
|
||||
<icon v-else :icon="faBoltLightning" :mask="faFile" transform="shrink-7 down-2" fixed-width/>
|
||||
<icon v-else-if="type === 'favorites'" :icon="faHeart" class="text-maroon" fixed-width/>
|
||||
<icon
|
||||
v-else-if="playlist.is_smart"
|
||||
:icon="faBoltLightning"
|
||||
:mask="faFile"
|
||||
fixed-width
|
||||
transform="shrink-7 down-2"
|
||||
/>
|
||||
<icon v-else :icon="faMusic" :mask="faFile" fixed-width transform="shrink-7 down-2"/>
|
||||
{{ playlist.name }}
|
||||
</a>
|
||||
|
||||
|
@ -30,15 +30,20 @@
|
|||
<script lang="ts" setup>
|
||||
import { faBoltLightning, faClockRotateLeft, faFile, faHeart, faMusic } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, nextTick, ref, toRefs } from 'vue'
|
||||
import { eventBus, pluralize, requireInjection, resolveSongsFromDragEvent, startDragging } from '@/utils'
|
||||
import { eventBus, pluralize, requireInjection } from '@/utils'
|
||||
import { favoriteStore, playlistStore } from '@/stores'
|
||||
import router from '@/router'
|
||||
import { MessageToasterKey } from '@/symbols'
|
||||
import { useDraggable, useDroppable } from '@/composables'
|
||||
|
||||
import ContextMenu from '@/components/playlist/PlaylistContextMenu.vue'
|
||||
|
||||
const { startDragging } = useDraggable('playlist')
|
||||
const { acceptsDrop, resolveDroppedSongs } = useDroppable(['songs', 'album', 'artist'])
|
||||
|
||||
const toaster = requireInjection(MessageToasterKey)
|
||||
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
const el = ref<HTMLLIElement>()
|
||||
|
||||
const props = withDefaults(defineProps<{ playlist: Playlist, type?: PlaylistType }>(), { type: 'playlist' })
|
||||
const { playlist, type } = toRefs(props)
|
||||
|
@ -64,35 +69,11 @@ const url = computed(() => {
|
|||
const hasContextMenu = computed(() => type.value === 'playlist')
|
||||
|
||||
const contentEditable = computed(() => {
|
||||
if (playlist.value.is_smart) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (playlist.value.is_smart) return false
|
||||
return type.value === 'playlist' || type.value === 'favorites'
|
||||
})
|
||||
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
if (!contentEditable.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const songs = await resolveSongsFromDragEvent(event)
|
||||
|
||||
if (!songs.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (type.value === 'favorites') {
|
||||
await favoriteStore.like(songs)
|
||||
} else if (type.value === 'playlist') {
|
||||
await playlistStore.addSongs(playlist.value, songs)
|
||||
toaster.value.success(`Added ${pluralize(songs.length, 'song')} into "${playlist.value.name}."`)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const openContextMenu = async (event: MouseEvent) => {
|
||||
const onContextMenu = async (event: MouseEvent) => {
|
||||
if (hasContextMenu.value) {
|
||||
await nextTick()
|
||||
router.go(`/playlist/${playlist.value.id}`)
|
||||
|
@ -100,7 +81,44 @@ const openContextMenu = async (event: MouseEvent) => {
|
|||
}
|
||||
}
|
||||
|
||||
const dragStart = (event: DragEvent) => startDragging(event, playlist.value, 'Playlist')
|
||||
const onDragStart = (event: DragEvent) => {
|
||||
if (type.value === 'playlist') {
|
||||
startDragging(event, playlist.value)
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
if (!contentEditable.value) return false
|
||||
if (!acceptsDrop(event)) return false
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer!.dropEffect = 'copy'
|
||||
el.value!.classList.add('droppable')
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const onDragLeave = () => el.value!.classList.remove('droppable')
|
||||
|
||||
const onDrop = async (event: DragEvent) => {
|
||||
el.value!.classList.remove('droppable')
|
||||
|
||||
if (!contentEditable.value) return false
|
||||
if (!acceptsDrop(event)) return false
|
||||
|
||||
const songs = await resolveDroppedSongs(event)
|
||||
|
||||
if (!songs?.length) return false
|
||||
|
||||
if (type.value === 'favorites') {
|
||||
await favoriteStore.like(songs)
|
||||
} else if (type.value === 'playlist') {
|
||||
await playlistStore.addSongs(playlist.value, songs)
|
||||
toaster.value.success(`Added ${pluralize(songs, 'song')} into "${playlist.value.name}."`)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, _playlist: Playlist): void => {
|
||||
switch (view) {
|
||||
|
@ -128,6 +146,12 @@ eventBus.on('LOAD_MAIN_CONTENT', (view: MainViewName, _playlist: Playlist): void
|
|||
user-select: none;
|
||||
overflow: hidden;
|
||||
|
||||
&.droppable {
|
||||
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||
border-radius: 4px;
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
::v-deep(a) {
|
||||
span {
|
||||
pointer-events: none;
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
</template>
|
||||
|
||||
<template v-slot:meta v-if="songs.length">
|
||||
<span>{{ pluralize(songs.length, 'song') }}</span>
|
||||
<span>{{ pluralize(songs, 'song') }}</span>
|
||||
<span>{{ duration }}</span>
|
||||
|
||||
<a
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
</template>
|
||||
|
||||
<template v-slot:meta v-if="songs.length">
|
||||
<span>{{ pluralize(songs.length, 'song') }}</span>
|
||||
<span>{{ pluralize(songs, 'song') }}</span>
|
||||
<span>{{ duration }}</span>
|
||||
<a
|
||||
v-if="allowDownload"
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
</template>
|
||||
|
||||
<template v-slot:meta v-if="songs.length">
|
||||
<span>{{ pluralize(songs.length, 'song') }}</span>
|
||||
<span>{{ pluralize(songs, 'song') }}</span>
|
||||
<span>{{ duration }}</span>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
</template>
|
||||
|
||||
<template v-slot:meta v-if="songs.length">
|
||||
<span>{{ pluralize(songs.length, 'song') }}</span>
|
||||
<span>{{ pluralize(songs, 'song') }}</span>
|
||||
<span>{{ duration }}</span>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
</template>
|
||||
|
||||
<template v-slot:meta v-if="songs.length">
|
||||
<span>{{ pluralize(songs.length, 'song') }}</span>
|
||||
<span>{{ pluralize(songs, 'song') }}</span>
|
||||
<span>{{ duration }}</span>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
@keydown.esc="close"
|
||||
>
|
||||
<section class="existing-playlists">
|
||||
<p>Add {{ pluralize(songs.length, 'song') }} to</p>
|
||||
<p>Add {{ pluralize(songs, 'song') }} to</p>
|
||||
|
||||
<ul>
|
||||
<template v-if="config.queue">
|
||||
|
|
|
@ -286,7 +286,7 @@ const submit = async () => {
|
|||
|
||||
try {
|
||||
await songStore.update(mutatedSongs.value, formData)
|
||||
toaster.value.success(`Updated ${pluralize(mutatedSongs.value.length, 'song')}.`)
|
||||
toaster.value.success(`Updated ${pluralize(mutatedSongs.value, 'song')}.`)
|
||||
close()
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
data-testid="song-card"
|
||||
draggable="true"
|
||||
tabindex="0"
|
||||
@dragstart="dragStart"
|
||||
@dragstart="onDragStart"
|
||||
@contextmenu.prevent="requestContextMenu"
|
||||
@dblclick.prevent="play"
|
||||
>
|
||||
|
@ -29,17 +29,20 @@
|
|||
<script lang="ts" setup>
|
||||
import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'
|
||||
import { toRefs } from 'vue'
|
||||
import { defaultCover, eventBus, pluralize, startDragging } from '@/utils'
|
||||
import { defaultCover, eventBus, pluralize } from '@/utils'
|
||||
import { queueStore } from '@/stores'
|
||||
import { playbackService } from '@/services'
|
||||
import { useDraggable } from '@/composables'
|
||||
|
||||
import LikeButton from '@/components/song/SongLikeButton.vue'
|
||||
|
||||
const props = defineProps<{ song: Song }>()
|
||||
const { song } = toRefs(props)
|
||||
|
||||
const { startDragging } = useDraggable('songs')
|
||||
|
||||
const requestContextMenu = (event: MouseEvent) => eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, song.value)
|
||||
const dragStart = (event: DragEvent) => startDragging(event, song.value, 'Song')
|
||||
const onDragStart = (event: DragEvent) => startDragging(event, [song.value])
|
||||
|
||||
const play = () => {
|
||||
queueStore.queueIfNotQueued(song.value)
|
||||
|
|
|
@ -105,7 +105,8 @@ import isMobile from 'ismobilejs'
|
|||
import { faAngleDown, faAngleUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { eventBus, requireInjection, startDragging } from '@/utils'
|
||||
import { eventBus, requireInjection } from '@/utils'
|
||||
import { useDraggable } from '@/composables'
|
||||
import {
|
||||
SelectedSongsKey,
|
||||
SongListConfigKey,
|
||||
|
@ -118,6 +119,8 @@ import {
|
|||
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
|
||||
import SongListItem from '@/components/song/SongListItem.vue'
|
||||
|
||||
const { startDragging } = useDraggable('songs')
|
||||
|
||||
const emit = defineEmits(['press:enter', 'press:delete', 'reorder', 'sort', 'scroll-breakpoint', 'scrolled-to-end'])
|
||||
|
||||
const [items] = requireInjection(SongsKey)
|
||||
|
@ -260,7 +263,7 @@ const rowDragStart = (row: SongRow, event: DragEvent) => {
|
|||
row.selected = true
|
||||
}
|
||||
|
||||
startDragging(event, selectedSongs.value, 'Song')
|
||||
startDragging(event, selectedSongs.value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -5,3 +5,4 @@ export * from './useContextMenu'
|
|||
export * from './useAuthorization'
|
||||
export * from './useNewVersionNotification'
|
||||
export * from './useThirdPartyServices'
|
||||
export * from './useDragAndDrop'
|
||||
|
|
145
resources/assets/js/composables/useDragAndDrop.ts
Normal file
145
resources/assets/js/composables/useDragAndDrop.ts
Normal file
|
@ -0,0 +1,145 @@
|
|||
import { arrayify, logger, pluralize } from '@/utils'
|
||||
import { albumStore, artistStore, playlistStore, songStore } from '@/stores'
|
||||
|
||||
type Draggable = Song | Song[] | Album | Artist | Playlist
|
||||
type DraggableType = 'songs' | 'album' | 'artist' | 'playlist'
|
||||
|
||||
const createGhostDragImage = (event: DragEvent, text: string): void => {
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
|
||||
let dragGhost = document.querySelector<HTMLElement>('#dragGhost')
|
||||
|
||||
if (!dragGhost) {
|
||||
// Create the element to be the ghost drag image.
|
||||
dragGhost = document.createElement('div')
|
||||
dragGhost.id = 'dragGhost'
|
||||
document.body.appendChild(dragGhost)
|
||||
}
|
||||
|
||||
dragGhost.innerText = text
|
||||
event.dataTransfer.setDragImage(dragGhost, 0, 0)
|
||||
}
|
||||
|
||||
const getDragType = (event: DragEvent) => {
|
||||
const types: DraggableType[] = ['songs', 'album', 'artist', 'playlist']
|
||||
|
||||
for (let i = 0, count = types.length; i < count; ++i) {
|
||||
if (event.dataTransfer?.types.includes(`application/x-koel.${types[i]}`)) return types[i]
|
||||
}
|
||||
}
|
||||
|
||||
export const useDraggable = (type: DraggableType) => {
|
||||
const startDragging = (event: DragEvent, dragged: Draggable) => {
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
|
||||
let text
|
||||
let data: any
|
||||
|
||||
switch (type) {
|
||||
case 'songs':
|
||||
dragged = arrayify(<Song>dragged)
|
||||
text = dragged.length === 1 ? `${dragged[0].title} by ${dragged[0].artist_name}` : pluralize(dragged, 'song')
|
||||
|
||||
data = dragged.map(song => song.id)
|
||||
break
|
||||
|
||||
case 'album':
|
||||
dragged = <Album>dragged
|
||||
text = `All songs in ${dragged.name}`
|
||||
data = dragged.id
|
||||
break
|
||||
|
||||
case 'artist':
|
||||
dragged = <Artist>dragged
|
||||
text = `All songs by ${dragged.name}`
|
||||
data = dragged.id
|
||||
break
|
||||
|
||||
case 'playlist':
|
||||
dragged = <Playlist>dragged
|
||||
text = dragged.name
|
||||
data = dragged.id
|
||||
break
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
event.dataTransfer.setData(`application/x-koel.${type}`, JSON.stringify(data))
|
||||
|
||||
createGhostDragImage(event, text)
|
||||
}
|
||||
|
||||
return {
|
||||
startDragging
|
||||
}
|
||||
}
|
||||
|
||||
export const useDroppable = (acceptedTypes: DraggableType[]) => {
|
||||
const acceptsDrop = (event: DragEvent) => {
|
||||
const type = getDragType(event)
|
||||
return type && acceptedTypes.includes(type)
|
||||
}
|
||||
|
||||
const getDroppedData = (event: DragEvent) => {
|
||||
const type = getDragType(event)
|
||||
|
||||
if (!type) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(event.dataTransfer?.getData(`application/x-koel.${type}`)!)
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse dropped data', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDroppedValue = async <T = Playlist> (event: DragEvent): Promise<T | undefined> => {
|
||||
try {
|
||||
switch (getDragType(event)) {
|
||||
case 'playlist':
|
||||
return playlistStore
|
||||
.byId(parseInt(event.dataTransfer!.getData('application/x-koel.playlist'))) as T | undefined
|
||||
default:
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, event)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDroppedSongs = async (event: DragEvent) => {
|
||||
try {
|
||||
const type = getDragType(event)
|
||||
if (!type) return <Song[]>[]
|
||||
|
||||
const data = getDroppedData(event)
|
||||
switch (type) {
|
||||
case 'songs':
|
||||
return songStore.byIds(<string[]>data)
|
||||
case 'album':
|
||||
const album = await albumStore.resolve(<number>data)
|
||||
return album ? await songStore.fetchForAlbum(album) : <Song[]>[]
|
||||
case 'artist':
|
||||
const artist = await artistStore.resolve(<number>data)
|
||||
return artist ? await songStore.fetchForArtist(artist) : <Song[]>[]
|
||||
case 'playlist':
|
||||
const playlist = await playlistStore.byId(<number>data)
|
||||
return playlist ? await songStore.fetchForPlaylist(playlist) : <Song[]>[]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, event)
|
||||
return <Song[]>[]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
acceptsDrop,
|
||||
resolveDroppedValue,
|
||||
resolveDroppedSongs
|
||||
}
|
||||
}
|
|
@ -32,7 +32,7 @@ export const useSongMenuMethods = (songs: Ref<Song[]>, close: Closure) => {
|
|||
|
||||
try {
|
||||
await playlistStore.addSongs(playlist, songs.value)
|
||||
toaster.value.success(`Added ${pluralize(songs.value.length, 'song')} into "${playlist.name}."`)
|
||||
toaster.value.success(`Added ${pluralize(songs.value, 'song')} into "${playlist.name}."`)
|
||||
} catch (error) {
|
||||
dialog.value.error('Something went wrong. Please try again.', 'Error')
|
||||
}
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
import { Directive } from 'vue'
|
||||
|
||||
export const droppable: Directive = {
|
||||
created: (el: HTMLElement, binding) => {
|
||||
el.addEventListener('dragenter', (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
el.classList.add('droppable')
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
el.addEventListener('dragover', (event: DragEvent) => event.preventDefault())
|
||||
el.addEventListener('dragleave', () => el.classList.remove('droppable'))
|
||||
|
||||
el.addEventListener('drop', (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
el.classList.remove('droppable')
|
||||
binding.value(event)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,3 +1,2 @@
|
|||
export * from './droppable'
|
||||
export * from './clickaway'
|
||||
export * from './focus'
|
||||
|
|
|
@ -24,7 +24,9 @@ const DEFAULT_VOLUME_VALUE = 7
|
|||
const VOLUME_INPUT_SELECTOR = '#volumeInput'
|
||||
|
||||
class PlaybackService {
|
||||
// @ts-ignore
|
||||
public player: Plyr
|
||||
// @ts-ignore
|
||||
private volumeInput: HTMLInputElement
|
||||
private repeatModes: RepeatMode[] = ['NO_REPEAT', 'REPEAT_ALL', 'REPEAT_ONE']
|
||||
private initialized = false
|
||||
|
|
8
resources/assets/js/types.d.ts
vendored
8
resources/assets/js/types.d.ts
vendored
|
@ -280,14 +280,6 @@ interface EqualizerPreset {
|
|||
gains: number[]
|
||||
}
|
||||
|
||||
type Draggable = Song | Song[] | Album | Artist | Playlist
|
||||
type DragType = 'Song' | 'Album' | 'Artist' | 'Playlist'
|
||||
|
||||
type DragData = {
|
||||
type: 'songs' | 'album' | 'artist' | 'playlist'
|
||||
value: string | string[] | number
|
||||
}
|
||||
|
||||
declare type PlaybackState = 'Stopped' | 'Playing' | 'Paused'
|
||||
declare type MainViewName =
|
||||
| 'Home'
|
||||
|
|
|
@ -1,113 +0,0 @@
|
|||
import { arrayify, logger, pluralize } from '@/utils'
|
||||
import { albumStore, artistStore, songStore } from '@/stores'
|
||||
|
||||
const createGhostDragImage = (event: DragEvent, text: string): void => {
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
|
||||
let dragGhost = document.querySelector<HTMLElement>('#dragGhost')
|
||||
|
||||
if (!dragGhost) {
|
||||
// Create the element to be the ghost drag image.
|
||||
dragGhost = document.createElement('div')
|
||||
dragGhost.id = 'dragGhost'
|
||||
document.body.appendChild(dragGhost)
|
||||
}
|
||||
|
||||
dragGhost.innerText = text
|
||||
event.dataTransfer.setDragImage(dragGhost, 0, 0)
|
||||
}
|
||||
|
||||
const startDragging = (event: DragEvent, dragged: Draggable, type: DragType): void => {
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
|
||||
let text
|
||||
let data: DragData
|
||||
|
||||
switch (type) {
|
||||
case 'Song':
|
||||
dragged = arrayify(dragged as Song)
|
||||
text = dragged.length === 1
|
||||
? `${dragged[0].title} by ${dragged[0].artist_name}`
|
||||
: pluralize(dragged.length, 'song')
|
||||
|
||||
data = {
|
||||
type: 'songs',
|
||||
value: dragged.map(song => song.id)
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'Album':
|
||||
dragged = dragged as Album
|
||||
text = `All songs in ${dragged.name}`
|
||||
|
||||
data = {
|
||||
type: 'album',
|
||||
value: dragged.id
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'Artist':
|
||||
dragged = dragged as Artist
|
||||
text = `All songs by ${dragged.name}`
|
||||
|
||||
data = {
|
||||
type: 'artist',
|
||||
value: dragged.id
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'Playlist':
|
||||
dragged = dragged as Playlist
|
||||
text = dragged.name
|
||||
|
||||
data = {
|
||||
type: 'playlist',
|
||||
value: dragged.id
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
default:
|
||||
throw Error(`Invalid drag type: ${type}`)
|
||||
}
|
||||
|
||||
event.dataTransfer.setData(data.type, '')
|
||||
event.dataTransfer.setData('application/x-koel.text+plain', JSON.stringify(data))
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
|
||||
createGhostDragImage(event, text)
|
||||
}
|
||||
|
||||
const resolveSongsFromDragEvent = async (event: DragEvent) => {
|
||||
if (!event.dataTransfer?.getData('application/x-koel.text+plain')) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data: DragData = JSON.parse(event.dataTransfer.getData('application/x-koel.text+plain'))
|
||||
|
||||
switch (data.type) {
|
||||
case 'songs':
|
||||
return songStore.byIds(data.value as string[])
|
||||
case 'album':
|
||||
const album = await albumStore.resolve(data.value as number)
|
||||
return album ? await songStore.fetchForAlbum(album) : []
|
||||
case 'artist':
|
||||
const artist = await artistStore.resolve(data.value as number)
|
||||
return artist ? await songStore.fetchForArtist(artist) : []
|
||||
default:
|
||||
logger.warn('Unhandled drag data type', data.type)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
startDragging,
|
||||
resolveSongsFromDragEvent
|
||||
}
|
|
@ -35,10 +35,10 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it.each([
|
||||
['foo<br>bar', "foo\nbar"],
|
||||
['foo<br/>bar', "foo\nbar"],
|
||||
['foo<br />bar', "foo\nbar"],
|
||||
['foo<br>bar<br/>baz', "foo\nbar\nbaz"]
|
||||
['foo<br>bar', 'foo\nbar'],
|
||||
['foo<br/>bar', 'foo\nbar'],
|
||||
['foo<br />bar', 'foo\nbar'],
|
||||
['foo<br>bar<br/>baz', 'foo\nbar\nbaz']
|
||||
])('converts <br> tags in %s to line breaks', (input, output) => expect(br2nl(input)).toEqual(output))
|
||||
|
||||
it.each([
|
||||
|
@ -53,5 +53,14 @@ new class extends UnitTestCase {
|
|||
[2, 'cat', 'cats'],
|
||||
[0, 'cat', 'cats']
|
||||
])('pluralizes %d %s', (count, noun, plural) => expect(pluralize(count, noun)).toEqual(`${count} ${plural}`))
|
||||
|
||||
it.each([
|
||||
[['foo'], 'cat', 'cat'],
|
||||
[['foo', 'bar'], 'cat', 'cats'],
|
||||
[[], 'cat', 'cats'],
|
||||
])(
|
||||
'pluralizes with array parameters',
|
||||
(arr, noun, plural) => expect(pluralize(arr, noun)).toEqual(`${arr.length} ${plural}`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,7 +44,7 @@ export const slugToTitle = (slug: string, separator = '-') => {
|
|||
return title.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export const pluralize = (count: number | undefined, singular: string) => {
|
||||
count = count ?? 0
|
||||
export const pluralize = (count: any[] | number | undefined, singular: string) => {
|
||||
count = Array.isArray(count) ? count.length : (count ?? 0)
|
||||
return count === 1 ? `${count} ${singular}` : `${count.toLocaleString()} ${singular}s`
|
||||
}
|
||||
|
|
|
@ -6,5 +6,4 @@ export * from './$'
|
|||
export * from './helpers'
|
||||
export * from './fileReader'
|
||||
export * from './directoryReader'
|
||||
export * from './dragAndDrop'
|
||||
export * from './logger'
|
||||
|
|
Loading…
Reference in a new issue