feat: select several playlist songs to remove, save or reorder them and drag songs into place
This commit is contained in:
1 parent
a72fc40519
commit
1b292fa5e3
13 files changed
+1074
-65
No files matched your search
@@ -1,22 +1,32 @@
|
||||
import type { MyPlaylistItem, Track } from '@/core/realtime'
|
||||
import type { SongItem } from '@/shared/ui'
|
||||
import type { MovePlan } from './track-order'
|
||||
import type { MyPlaylist, MyPlaylistItem, Track } from '@/core/realtime'
|
||||
import type { SongAction } from '@/shared/ui'
|
||||
import { ArrowLeft, CirclePlus, ListMusic, ListPlus, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '@/core/api'
|
||||
import { errorMessage } from '@/core/errors'
|
||||
import { roomStore, sameTrack } from '@/core/room'
|
||||
import { enqueue, sourceName } from '@/features/queue'
|
||||
import { roomStore } from '@/core/room'
|
||||
import { enqueue } from '@/features/queue'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { EmptyState, Skeleton, SongList } from '@/shared/ui'
|
||||
import { ConfirmDialog, EmptyState, Skeleton } from '@/shared/ui'
|
||||
import { FavoritesMark, PlaylistCover } from './playlist-cover'
|
||||
import { formatPlaylistDate } from './playlist-date'
|
||||
import { PlaylistMenu } from './playlist-menu'
|
||||
import { PlaylistTracks } from './playlist-tracks'
|
||||
import { playlistsStore, trackKey } from './playlists-store'
|
||||
import { saveToPlaylistLabel } from './save-label'
|
||||
import { SaveToPlaylistDialog } from './save-to-playlist'
|
||||
import { SelectionBar } from './selection-bar'
|
||||
import { TrackEdits } from './track-edits'
|
||||
import { dropAt } from './track-order'
|
||||
import { noSelection, retain, selectAll } from './track-selection'
|
||||
import './playlists.css'
|
||||
|
||||
function keysOf(items: readonly MyPlaylistItem[]): string[] {
|
||||
return items.map(item => trackKey(item.track))
|
||||
}
|
||||
|
||||
export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void }) {
|
||||
const playlist = useStore(playlistsStore, s => s.playlists?.find(p => p.id === id) ?? null)
|
||||
const listed = useStore(playlistsStore, s => s.playlists !== null)
|
||||
@@ -26,33 +36,82 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
|
||||
const [items, setItems] = useState<MyPlaylistItem[] | null>(null)
|
||||
const [failed, setFailed] = useState<string | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [selection, setSelection] = useState(noSelection)
|
||||
// 点了“选择”:没有选中项时也显示勾选框。有选中项时总是处于多选模式。
|
||||
const [picking, setPicking] = useState(false)
|
||||
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
|
||||
const [savingSelected, setSavingSelected] = useState(false)
|
||||
const [confirmingRemoval, setConfirmingRemoval] = useState(false)
|
||||
const [edits] = useState(() => new TrackEdits(() => setAttempt(n => n + 1)))
|
||||
// 歌单的更新时间变了就重新读取:加入、移出、移动和复制都会推进它,歌曲数不变时顺序也可能变了。
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const usable = edits.read()
|
||||
api.listMyPlaylistItems(id).then(({ items }) => {
|
||||
if (!active)
|
||||
if (!active || !usable())
|
||||
return
|
||||
setItems(items)
|
||||
setFailed(null)
|
||||
setSelection(selection => retain(selection, keysOf(items)))
|
||||
}, (error: unknown) => {
|
||||
if (active)
|
||||
if (active && usable())
|
||||
setFailed(errorMessage(error))
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [id, attempt, playlist?.updatedAtMs])
|
||||
}, [id, attempt, playlist?.updatedAtMs, edits])
|
||||
useEffect(() => {
|
||||
const refresh = () => setAttempt(n => n + 1)
|
||||
window.addEventListener('focus', refresh)
|
||||
return () => window.removeEventListener('focus', refresh)
|
||||
}, [])
|
||||
const selecting = picking || selection.keys.size > 0
|
||||
// 对话框和菜单自己处理 Esc。
|
||||
useEffect(() => {
|
||||
if (!selecting)
|
||||
return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || (event.target instanceof Element && event.target.closest('dialog, [popover]')))
|
||||
return
|
||||
setSelection(noSelection)
|
||||
setPicking(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [selecting])
|
||||
const now = useStore(playlistsStore, s => s.loadedAt)
|
||||
// 读到了自己的歌单,里面却没有它:在别处删掉了,或者换了账号。
|
||||
const gone = listed && !playlist
|
||||
// 读不到时不再显示之前的曲目:它们可能已经不在了,也可能属于之前的账号。
|
||||
const shown = gone || failed ? null : items
|
||||
|
||||
const exitSelection = () => {
|
||||
setSelection(noSelection)
|
||||
setPicking(false)
|
||||
}
|
||||
|
||||
const move = (target: MyPlaylist, current: MyPlaylistItem[], plan: MovePlan<string>) => {
|
||||
const byKey = new Map(current.map(item => [trackKey(item.track), item]))
|
||||
const trackOf = (key: string) => byKey.get(key)!.track
|
||||
const tracks = plan.moved.map(trackOf)
|
||||
const before = plan.before === undefined ? undefined : trackOf(plan.before)
|
||||
setItems(plan.order.map(key => byKey.get(key)!))
|
||||
void edits.run(() => playlistsStore.moveItems(target, tracks, before), () => setItems(current))
|
||||
}
|
||||
|
||||
const remove = async (target: MyPlaylist, current: MyPlaylistItem[], tracks: Track[]) => {
|
||||
const gone = new Set(tracks.map(trackKey))
|
||||
const rest = current.filter(item => !gone.has(trackKey(item.track)))
|
||||
const previousSelection = selection
|
||||
setItems(rest)
|
||||
setSelection(retain(selection, keysOf(rest)))
|
||||
await edits.run(() => playlistsStore.removeItems(target, tracks), () => {
|
||||
setItems(current)
|
||||
setSelection(previousSelection)
|
||||
})
|
||||
}
|
||||
|
||||
let body
|
||||
if (gone) {
|
||||
body = (
|
||||
@@ -94,57 +153,53 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
|
||||
)
|
||||
}
|
||||
else {
|
||||
const actions = ({ track, meta }: MyPlaylistItem): SongAction[] => {
|
||||
const blocked = track.source === 'link' && ephemeral
|
||||
return [
|
||||
{
|
||||
icon: CirclePlus,
|
||||
label: blocked ? '临时房间不能点链接里的歌' : `点歌${bookTitle(meta.title)}`,
|
||||
disabled: !connected || blocked,
|
||||
run: async () => {
|
||||
await enqueue(track)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: ListPlus,
|
||||
label: saveToPlaylistLabel(meta.title),
|
||||
secondary: true,
|
||||
run: async () => setSaving({ track, title: meta.title }),
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: `把${bookTitle(meta.title)}移出这个歌单`,
|
||||
disabled: !playlist,
|
||||
secondary: true,
|
||||
run: async () => {
|
||||
if (playlist)
|
||||
await remove(playlist, items, [track])
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
body = (
|
||||
<SongList
|
||||
songs={items.map(({ track, meta }): SongItem => ({
|
||||
key: trackKey(track),
|
||||
title: meta.title,
|
||||
artist: meta.artist || '未知艺术家',
|
||||
album: meta.album,
|
||||
coverUrl: meta.coverUrl,
|
||||
durationMs: meta.durationMs,
|
||||
sourceUrl: meta.pageUrl,
|
||||
sourceName: sourceName(track),
|
||||
playing: sameTrack(track, playing),
|
||||
}))}
|
||||
actions={(song, index) => {
|
||||
const { track } = items[index]
|
||||
const blocked = track.source === 'link' && ephemeral
|
||||
return [
|
||||
{
|
||||
icon: CirclePlus,
|
||||
label: blocked ? '临时房间不能点链接里的歌' : `点歌${bookTitle(song.title)}`,
|
||||
disabled: !connected || blocked,
|
||||
run: async () => {
|
||||
await enqueue(track)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: ListPlus,
|
||||
label: saveToPlaylistLabel(song.title),
|
||||
secondary: true,
|
||||
run: async () => setSaving({ track, title: song.title }),
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: `把${bookTitle(song.title)}移出这个歌单`,
|
||||
disabled: !playlist,
|
||||
secondary: true,
|
||||
run: async () => {
|
||||
if (!playlist)
|
||||
return
|
||||
// 失败时歌单可能已经在别处变了,重新读取。
|
||||
if (await playlistsStore.removeItems(playlist, [track]))
|
||||
setItems(list => list?.filter(item => !sameTrack(item.track, track)) ?? list)
|
||||
else
|
||||
setAttempt(n => n + 1)
|
||||
},
|
||||
},
|
||||
]
|
||||
<PlaylistTracks
|
||||
items={items}
|
||||
playing={playing}
|
||||
selection={selection}
|
||||
selecting={selecting}
|
||||
actions={actions}
|
||||
onSelect={setSelection}
|
||||
onMove={(plan) => {
|
||||
if (playlist)
|
||||
move(playlist, items, plan)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const keys = items ? keysOf(items) : []
|
||||
const selected = items?.filter(item => selection.keys.has(trackKey(item.track))).map(item => item.track) ?? []
|
||||
return (
|
||||
<section className="page-content my-playlist-page" aria-labelledby="my-playlist-title">
|
||||
<button type="button" className="button-ghost my-playlist-back text-ui-sm" aria-label="返回我的歌单" onClick={onBack}>
|
||||
@@ -163,12 +218,61 @@ export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void
|
||||
{`${playlist.trackCount} 首,更新于${formatPlaylistDate(playlist.updatedAtMs, now)},创建于${formatPlaylistDate(playlist.createdAtMs, now)}`}
|
||||
</p>
|
||||
)}
|
||||
{playlist && !playlist.favorites && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" onDeleted={onBack} />}
|
||||
<div className="my-playlist-actions">
|
||||
{shown && shown.length > 0 && (
|
||||
<button type="button" className="button-secondary my-playlist-select" onClick={() => selecting ? exitSelection() : setPicking(true)}>
|
||||
{selecting ? '完成' : '选择'}
|
||||
</button>
|
||||
)}
|
||||
{playlist && !playlist.favorites && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" onDeleted={onBack} />}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{body}
|
||||
{/* 选中数的播报常驻在页面上:选择栏要等选中第一首才出现,跟着它出现的播报区域读屏软件不会播报。 */}
|
||||
<p className="sr-only" role="status">{shown && selected.length > 0 ? `已选 ${selected.length} 首` : ''}</p>
|
||||
{!connected && shown?.length ? <p className="text-ui-caption muted my-playlist-hint">进入房间之后才能点歌。</p> : null}
|
||||
{playlist && shown && selected.length > 0 && (
|
||||
<SelectionBar
|
||||
count={selected.length}
|
||||
allSelected={selected.length === shown.length}
|
||||
onSave={() => setSavingSelected(true)}
|
||||
onTop={() => {
|
||||
const plan = dropAt(keys, selection.keys, 0)
|
||||
if (plan)
|
||||
move(playlist, shown, plan)
|
||||
}}
|
||||
onBottom={() => {
|
||||
const plan = dropAt(keys, selection.keys, keys.length)
|
||||
if (plan)
|
||||
move(playlist, shown, plan)
|
||||
}}
|
||||
onRemove={() => {
|
||||
if (selected.length > 1)
|
||||
setConfirmingRemoval(true)
|
||||
else
|
||||
void remove(playlist, shown, selected)
|
||||
}}
|
||||
onToggleAll={() => setSelection(selected.length === shown.length ? noSelection : selectAll(keys))}
|
||||
onClose={exitSelection}
|
||||
/>
|
||||
)}
|
||||
{saving && <SaveToPlaylistDialog track={saving.track} title={saving.title} onClose={() => setSaving(null)} />}
|
||||
{playlist && savingSelected && <SaveToPlaylistDialog from={playlist} tracks={selected} onClose={() => setSavingSelected(false)} />}
|
||||
{playlist && shown && confirmingRemoval && (
|
||||
<ConfirmDialog
|
||||
title="移出歌曲"
|
||||
confirmLabel="移出"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
setConfirmingRemoval(false)
|
||||
void remove(playlist, shown, selected)
|
||||
}}
|
||||
onClose={() => setConfirmingRemoval(false)}
|
||||
>
|
||||
{`把 ${selected.length} 首歌移出这个歌单?`}
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { KeyboardEvent, MouseEvent } from 'react'
|
||||
import type { MovePlan } from './track-order'
|
||||
import type { Selection } from './track-selection'
|
||||
import type { MyPlaylistItem, Track } from '@/core/realtime'
|
||||
import type { SongAction } from '@/shared/ui'
|
||||
import { GripVertical } from 'lucide-react'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { sameTrack } from '@/core/room'
|
||||
import { sourceName } from '@/features/queue'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { formatDuration } from '@/shared/lib/time'
|
||||
import { AsyncIconButton, PlayingBars, SongArtwork } from '@/shared/ui'
|
||||
import { trackKey } from './playlists-store'
|
||||
import { moveByOne } from './track-order'
|
||||
import { selectAll, selectRange, toggle } from './track-selection'
|
||||
import { useTrackDrag } from './use-track-drag'
|
||||
|
||||
function rowOf(list: HTMLElement | null, key: string): Element | null {
|
||||
return list?.querySelector(`[data-key="${CSS.escape(key)}"]`) ?? null
|
||||
}
|
||||
|
||||
// 多选时焦点落在勾选框上,读屏软件才读得出这一行是否选中;平时落在行上。
|
||||
function focusRow(row: Element | null | undefined, selecting: boolean): void {
|
||||
const target = selecting ? row?.querySelector('.playlist-track-check') : row
|
||||
if (target instanceof HTMLElement)
|
||||
target.focus()
|
||||
}
|
||||
|
||||
export function PlaylistTracks({ items, playing, selection, selecting, actions, onSelect, onMove }: {
|
||||
items: MyPlaylistItem[]
|
||||
playing: Track | undefined
|
||||
selection: Selection
|
||||
selecting: boolean
|
||||
actions: (item: MyPlaylistItem) => SongAction[]
|
||||
onSelect: (selection: Selection) => void
|
||||
onMove: (plan: MovePlan<string>) => void
|
||||
}) {
|
||||
const listRef = useRef<HTMLOListElement>(null)
|
||||
const lineRef = useRef<HTMLDivElement>(null)
|
||||
const labelRef = useRef<HTMLDivElement>(null)
|
||||
const focusAfterMoveRef = useRef<string | null>(null)
|
||||
// 最后获得焦点的行。勾选框随多选出现和消失,焦点在它上面时会丢。
|
||||
const focusedRef = useRef<string | null>(null)
|
||||
const keys = items.map(item => trackKey(item.track))
|
||||
const { dragging, onPointerDown } = useTrackDrag({
|
||||
listRef,
|
||||
lineRef,
|
||||
labelRef,
|
||||
keys,
|
||||
selected: selection.keys,
|
||||
onDrop: (plan) => {
|
||||
if (plan)
|
||||
onMove(plan)
|
||||
},
|
||||
})
|
||||
|
||||
// 重新排列时 React 可能把获得焦点的行移出再插回,焦点会丢。
|
||||
useLayoutEffect(() => {
|
||||
const key = focusAfterMoveRef.current
|
||||
if (key === null)
|
||||
return
|
||||
focusAfterMoveRef.current = null
|
||||
focusRow(rowOf(listRef.current, key), selecting)
|
||||
}, [items, selecting])
|
||||
|
||||
// 进出多选时,焦点留在原来那一行,落到这一行在新模式下的位置。
|
||||
useLayoutEffect(() => {
|
||||
const key = focusedRef.current
|
||||
const active = document.activeElement
|
||||
if (key === null || (active !== null && active !== document.body && !active.matches('.playlist-track')))
|
||||
return
|
||||
focusRow(rowOf(listRef.current, key), selecting)
|
||||
}, [selecting])
|
||||
|
||||
const onRowClick = (event: MouseEvent, key: string) => {
|
||||
if ((event.target as Element).closest('button, a'))
|
||||
return
|
||||
if (event.shiftKey)
|
||||
onSelect(selectRange(selection, keys, key))
|
||||
else if (event.metaKey || event.ctrlKey || selecting)
|
||||
onSelect(toggle(selection, key))
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLOListElement>) => {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'a') {
|
||||
event.preventDefault()
|
||||
onSelect(selectAll(keys))
|
||||
return
|
||||
}
|
||||
// 其余按键只在焦点落在行本身或它的勾选框上时处理,行里的按钮保持原来的行为。勾选框上的空格由浏览器切换。
|
||||
const target = event.target as HTMLElement
|
||||
const row = target.closest<HTMLElement>('[data-key]')
|
||||
const key = row?.dataset.key
|
||||
if (!row || key === undefined || (target !== row && !target.matches('.playlist-track-check')))
|
||||
return
|
||||
if (event.key === ' ' && selecting && target === row) {
|
||||
event.preventDefault()
|
||||
onSelect(toggle(selection, key))
|
||||
return
|
||||
}
|
||||
if ((event.key !== 'ArrowUp' && event.key !== 'ArrowDown') || event.metaKey || event.ctrlKey || event.shiftKey)
|
||||
return
|
||||
event.preventDefault()
|
||||
const delta = event.key === 'ArrowUp' ? -1 : 1
|
||||
if (!event.altKey) {
|
||||
focusRow(delta < 0 ? row.previousElementSibling : row.nextElementSibling, selecting)
|
||||
return
|
||||
}
|
||||
const plan = moveByOne(keys, selection.keys.has(key) ? selection.keys : new Set([key]), delta)
|
||||
if (!plan)
|
||||
return
|
||||
focusAfterMoveRef.current = key
|
||||
onMove(plan)
|
||||
}
|
||||
|
||||
let label = ''
|
||||
if (dragging) {
|
||||
const [first] = dragging
|
||||
label = dragging.size > 1 ? `${dragging.size} 首` : items.find(item => trackKey(item.track) === first)?.meta.title ?? ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="playlist-tracks" data-dragging={dragging ? true : undefined}>
|
||||
<ol ref={listRef} className="song-list" onKeyDown={onKeyDown}>
|
||||
{items.map((item, index) => {
|
||||
const { track, meta } = item
|
||||
const key = keys[index]
|
||||
const selected = selection.keys.has(key)
|
||||
const source = sourceName(track)
|
||||
const isPlaying = sameTrack(track, playing)
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
data-key={key}
|
||||
className="song-row playlist-track"
|
||||
tabIndex={selecting ? -1 : 0}
|
||||
data-playing={isPlaying || undefined}
|
||||
data-selected={selected || undefined}
|
||||
data-dragging={dragging?.has(key) || undefined}
|
||||
onClick={event => onRowClick(event, key)}
|
||||
onFocus={() => {
|
||||
focusedRef.current = key
|
||||
}}
|
||||
onPointerDown={event => onPointerDown(event, key)}
|
||||
// 按住封面拖动时,浏览器会开始原生的图片拖拽并取消指针事件,排序的拖动随之中断。
|
||||
onDragStart={event => event.preventDefault()}
|
||||
>
|
||||
{selecting
|
||||
? <input type="checkbox" className="playlist-track-check" checked={selected} readOnly aria-label={`选择${bookTitle(meta.title)}`} />
|
||||
: <span className="song-index text-ui-sm muted">{String(index + 1).padStart(2, '0')}</span>}
|
||||
{/* 多选时点封面也是切换选中,不打开音源页面。 */}
|
||||
<SongArtwork coverUrl={meta.coverUrl} title={meta.title} href={selecting ? undefined : meta.pageUrl} sourceName={source} />
|
||||
<div className="song-copy">
|
||||
<span className="song-title">
|
||||
{isPlaying && <PlayingBars />}
|
||||
<strong className="text-ui-base truncate">{meta.title}</strong>
|
||||
</span>
|
||||
<span className="text-ui-sm muted truncate">{[meta.artist || '未知艺术家', source].filter(Boolean).join(' · ')}</span>
|
||||
</div>
|
||||
<span className="song-album text-ui-sm muted truncate">{meta.album}</span>
|
||||
<span className="song-duration text-ui-sm muted">{meta.durationMs > 0 ? formatDuration(meta.durationMs) : ''}</span>
|
||||
{actions(item).map(action => (
|
||||
<AsyncIconButton key={action.label} icon={action.icon} className={action.secondary ? 'song-action song-action-secondary' : 'song-action'} label={action.label} disabled={action.disabled} onAction={action.run} />
|
||||
))}
|
||||
<span className="playlist-track-handle" data-drag-handle aria-hidden="true">
|
||||
<GripVertical size={18} strokeWidth={1.7} />
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
{dragging && <div ref={lineRef} className="playlist-drop-line" />}
|
||||
{dragging && createPortal(<div ref={labelRef} className="playlist-drag-label text-ui-sm">{label}</div>, document.body)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -399,6 +399,30 @@ describe('playlistsStore', () => {
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([changed])
|
||||
})
|
||||
|
||||
// 详情页的用法:移动经过 TrackEdits 排队。CONFLICT 说明歌单在别处变了,排在后面的移动基于过时的顺序。
|
||||
it('排队的移动遇到 CONFLICT:提示服务端的文案,回到这次移动之前,后面的不再发出,结束后重新读取', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
const { TrackEdits } = await import('./track-edits')
|
||||
let rereads = 0
|
||||
const edits = new TrackEdits(() => rereads++)
|
||||
failMove = true
|
||||
let shown = '第一次移动之后'
|
||||
void edits.run(() => playlistsStore.moveItems(favorites, [rain], sunny), () => {
|
||||
shown = '移动之前'
|
||||
})
|
||||
shown = '第二次移动之后'
|
||||
const done = edits.run(() => playlistsStore.moveItems(favorites, [sunny]), () => {
|
||||
shown = '第一次移动之后'
|
||||
})
|
||||
await settle()
|
||||
await done
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/moves'])
|
||||
expect(shown).toBe('移动之前')
|
||||
expect(rereads).toBe(1)
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([changed])
|
||||
})
|
||||
|
||||
it('游客不读取歌单', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
playlistsStore.start()
|
||||
|
||||
@@ -109,9 +109,39 @@
|
||||
.my-playlist-name { display: flex; align-items: center; gap: 10px; max-width: 100%; font-size: calc(var(--text-ui-xl) + 8px); font-weight: 700; line-height: 1.2; }
|
||||
.my-playlist-name > span.my-playlist-name-pending { display: block; width: 12em; max-width: 100%; height: 0.9em; border-radius: 6px; background: var(--color-surface-hover); }
|
||||
.my-playlist-name > span { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
|
||||
.my-playlist-menu { margin-top: 4px; border: 1px solid var(--color-border); border-radius: 50%; }
|
||||
.my-playlist-actions { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
|
||||
.my-playlist-select { min-width: 64px; border-radius: 999px; }
|
||||
.my-playlist-menu { border: 1px solid var(--color-border); border-radius: 50%; }
|
||||
.my-playlist-hint { margin-top: 12px; }
|
||||
|
||||
.playlist-tracks { position: relative; }
|
||||
.playlist-track { user-select: none; -webkit-user-select: none; }
|
||||
.playlist-track:is(:focus-visible, :has(> .playlist-track-check:focus-visible)) { outline: 2px solid var(--color-brand); outline-offset: -2px; }
|
||||
.playlist-track[data-selected], .playlist-track[data-selected]:hover { background: var(--color-selected); }
|
||||
.playlist-track[data-dragging] { opacity: .4; }
|
||||
.playlist-track-check { margin: 0 3px; }
|
||||
.playlist-track-handle { display: grid; place-items: center; flex-shrink: 0; width: 24px; height: 36px; margin-left: -4px; color: var(--color-foreground-subtlest); cursor: grab; touch-action: none; -webkit-touch-callout: none; }
|
||||
.playlist-tracks[data-dragging], .playlist-tracks[data-dragging] .playlist-track-handle { cursor: grabbing; }
|
||||
.playlist-tracks[data-dragging] .playlist-track:not([data-selected]):hover { background: transparent; }
|
||||
@media (hover: hover) {
|
||||
.playlist-track-handle { opacity: 0; transition: opacity 120ms ease; }
|
||||
.playlist-tracks:not([data-dragging]) .playlist-track:is(:hover, :focus-visible, :has(> .playlist-track-check:focus-visible)) .playlist-track-handle { opacity: 1; }
|
||||
}
|
||||
.playlist-drop-line { position: absolute; top: 0; left: 8px; right: 8px; height: 2px; margin-top: -1px; border-radius: 1px; background: var(--color-brand); pointer-events: none; visibility: hidden; }
|
||||
.playlist-drag-label { position: fixed; top: 0; left: 0; z-index: 40; max-width: 240px; padding: 4px 10px; overflow: hidden; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-popover); color: var(--color-foreground); box-shadow: 0 8px 24px var(--color-shadow); text-overflow: ellipsis; white-space: nowrap; pointer-events: none; visibility: hidden; }
|
||||
|
||||
.playlist-selection-bar { position: sticky; bottom: 16px; z-index: 5; display: flex; align-items: center; gap: 2px; width: fit-content; max-width: 100%; margin: 20px auto 0; padding: 6px 6px 6px 16px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-popover); box-shadow: 0 12px 32px var(--color-shadow); animation: selection-bar-in 200ms cubic-bezier(.2, .8, .2, 1) both; }
|
||||
.playlist-selection-count { margin-right: 8px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.playlist-bar-button { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 6px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--color-foreground); white-space: nowrap; }
|
||||
.playlist-bar-button:hover { background: var(--color-hover); }
|
||||
.playlist-bar-button > svg { color: var(--color-foreground-subtle); }
|
||||
.playlist-bar-close { margin-left: 4px; color: var(--color-foreground-subtle); }
|
||||
:root[data-performance='low'] .playlist-selection-bar { animation-name: fade-in; }
|
||||
@keyframes selection-bar-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@container playlists (max-width: 720px) {
|
||||
.playlist-table { --playlist-columns: minmax(0, 1fr) 36px; }
|
||||
.playlist-table-head, .playlist-col-trackCount, .playlist-col-updatedAt, .playlist-col-createdAt { display: none; }
|
||||
@@ -122,6 +152,10 @@
|
||||
.playlists-controls { justify-content: space-between; }
|
||||
.playlist-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 12px; }
|
||||
}
|
||||
@container my-playlist (max-width: 640px) {
|
||||
.playlist-bar-button { justify-content: center; width: 36px; padding: 0; }
|
||||
.playlist-bar-button > span { display: none; }
|
||||
}
|
||||
@container my-playlist (max-width: 560px) {
|
||||
.my-playlist-header { flex-direction: column; align-items: flex-start; gap: 16px; padding-bottom: 20px; }
|
||||
.playlist-cover-lg { width: 176px; }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ListPlus, Plus } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { profileStore } from '@/features/settings'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { IconButton, Modal, Skeleton } from '@/shared/ui'
|
||||
import { IconButton, Modal, notificationStore, Skeleton } from '@/shared/ui'
|
||||
import { openAccountSettings } from './account-settings'
|
||||
import { CreatePlaylistForm } from './create-playlist-form'
|
||||
import { FavoritesMark, PlaylistCover } from './playlist-cover'
|
||||
@@ -23,15 +23,28 @@ function arrange(playlists: readonly MyPlaylist[], order: readonly string[]): My
|
||||
return sortPlaylists(playlists, byUpdate).sort((a, b) => at(a) - at(b))
|
||||
}
|
||||
|
||||
export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track, title: string, onClose: () => void }) {
|
||||
function savedMessage(saved: readonly MyPlaylist[], skipped: number): string {
|
||||
const where = saved.length === 1 ? `“${saved[0].name}”` : ` ${saved.length} 个歌单`
|
||||
return `已保存到${where}${skipped > 0 ? `,其中 ${skipped} 首已经在里面` : ''}`
|
||||
}
|
||||
|
||||
// 一首歌:勾选立即生效。歌单里选中的多首:勾好目标歌单后一起保存,来源歌单不在列表里。
|
||||
type SaveToPlaylistProps = { onClose: () => void } & ({ track: Track, title: string } | { from: MyPlaylist, tracks: Track[] })
|
||||
|
||||
export function SaveToPlaylistDialog(props: SaveToPlaylistProps) {
|
||||
const { onClose } = props
|
||||
const track = 'track' in props ? props.track : null
|
||||
const from = 'from' in props ? props.from : null
|
||||
const account = useStore(profileStore, s => s.account)
|
||||
const playlists = useStore(playlistsStore, s => s.playlists)
|
||||
const error = useStore(playlistsStore, s => s.error)
|
||||
const included = useStore(playlistsStore, s => s.memberships.get(trackKey(track))) ?? none
|
||||
const included = useStore(playlistsStore, s => track ? s.memberships.get(trackKey(track)) : undefined) ?? none
|
||||
const [picked, setPicked] = useState<ReadonlySet<string>>(none)
|
||||
const [order, setOrder] = useState<string[] | null>(null)
|
||||
const [readFailed, setReadFailed] = useState(false)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
// 从新建页回到列表时要获得焦点的元素:取消时是“新建歌单”,建好之后是新歌单的勾选框,聚焦的同时把它滚进视野。
|
||||
const returnFocusRef = useRef<string | null>(null)
|
||||
@@ -40,7 +53,7 @@ export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track,
|
||||
if (!account)
|
||||
return
|
||||
let active = true
|
||||
void Promise.all([playlistsStore.load(), playlistsStore.containing(track)]).then(([, read]) => {
|
||||
void Promise.all([playlistsStore.load(), track ? playlistsStore.containing(track) : true]).then(([, read]) => {
|
||||
if (!active)
|
||||
return
|
||||
setReadFailed(!read)
|
||||
@@ -57,10 +70,21 @@ export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track,
|
||||
returnFocusRef.current = null
|
||||
}, [creating])
|
||||
|
||||
const rows = playlists && order ? arrange(playlists, order) : null
|
||||
const rows = playlists && order ? arrange(playlists, order).filter(p => p.id !== from?.id) : null
|
||||
|
||||
const choose = (playlist: MyPlaylist, checked: boolean) => {
|
||||
void playlistsStore.setMembership(playlist, track, checked)
|
||||
if (track) {
|
||||
void playlistsStore.setMembership(playlist, track, checked)
|
||||
return
|
||||
}
|
||||
setPicked((current) => {
|
||||
const next = new Set(current)
|
||||
if (checked)
|
||||
next.add(playlist.id)
|
||||
else
|
||||
next.delete(playlist.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const backToList = (focus: string) => {
|
||||
@@ -68,6 +92,16 @@ export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track,
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
const save = async (source: MyPlaylist, tracks: Track[], targets: MyPlaylist[]) => {
|
||||
setSaving(true)
|
||||
const results = await Promise.all(targets.map(target => playlistsStore.copyItems(source, target, tracks)))
|
||||
const saved = targets.filter((_, index) => results[index] !== null)
|
||||
const skipped = results.reduce<number>((sum, added) => added === null ? sum : sum + tracks.length - added, 0)
|
||||
if (saved.length > 0)
|
||||
notificationStore.success(savedMessage(saved, skipped))
|
||||
onClose()
|
||||
}
|
||||
|
||||
let body
|
||||
if (!account) {
|
||||
body = (
|
||||
@@ -123,7 +157,7 @@ export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track,
|
||||
else {
|
||||
body = (
|
||||
<>
|
||||
<p className="save-playlist-subject text-ui-caption muted truncate">{title}</p>
|
||||
<p className="save-playlist-subject text-ui-caption muted truncate">{'tracks' in props ? `已选 ${props.tracks.length} 首` : props.title}</p>
|
||||
<ul ref={listRef} className="save-playlist-list">
|
||||
{rows.map(playlist => (
|
||||
<li key={playlist.id}>
|
||||
@@ -131,7 +165,8 @@ export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track,
|
||||
<input
|
||||
type="checkbox"
|
||||
value={playlist.id}
|
||||
checked={included.has(playlist.id)}
|
||||
checked={(track ? included : picked).has(playlist.id)}
|
||||
disabled={saving}
|
||||
onChange={event => choose(playlist, event.target.checked)}
|
||||
/>
|
||||
<PlaylistCover cover={playlist.coverUrl} favorites={playlist.favorites} size="sm" />
|
||||
@@ -144,12 +179,24 @@ export function SaveToPlaylistDialog({ track, title, onClose }: { track: Track,
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<button type="button" className="save-playlist-row save-playlist-new" onClick={() => setCreating(true)}>
|
||||
<button type="button" className="save-playlist-row save-playlist-new" disabled={saving} onClick={() => setCreating(true)}>
|
||||
<span className="save-playlist-new-tile"><Plus strokeWidth={1.7} aria-hidden="true" /></span>
|
||||
<span className="text-ui-base">新建歌单</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{'tracks' in props && (
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button-primary"
|
||||
disabled={saving || picked.size === 0}
|
||||
onClick={() => void save(props.from, props.tracks, rows.filter(p => picked.has(p.id)))}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { ArrowDownToLine, ArrowUpToLine, ListChecks, ListPlus, ListX, Trash2, X } from 'lucide-react'
|
||||
import { IconButton } from '@/shared/ui'
|
||||
|
||||
// 窄屏只显示图标,按钮的名称要包含显示的文字。
|
||||
function BarButton({ icon: Icon, text, label, onClick }: { icon: LucideIcon, text: string, label: string, onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className="playlist-bar-button" aria-label={label} onClick={onClick}>
|
||||
<Icon size={18} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{text}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function SelectionBar({ count, allSelected, onSave, onTop, onBottom, onRemove, onToggleAll, onClose }: {
|
||||
count: number
|
||||
allSelected: boolean
|
||||
onSave: () => void
|
||||
onTop: () => void
|
||||
onBottom: () => void
|
||||
onRemove: () => void
|
||||
onToggleAll: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="playlist-selection-bar" role="toolbar" aria-label="选中的歌">
|
||||
<span className="playlist-selection-count text-ui-sm">{`已选 ${count} 首`}</span>
|
||||
<BarButton icon={ListPlus} text="保存到歌单" label="把选中的歌保存到歌单" onClick={onSave} />
|
||||
<BarButton icon={ArrowUpToLine} text="移到最前" label="把选中的歌移到最前" onClick={onTop} />
|
||||
<BarButton icon={ArrowDownToLine} text="移到最后" label="把选中的歌移到最后" onClick={onBottom} />
|
||||
<BarButton icon={Trash2} text="移出" label="把选中的歌移出这个歌单" onClick={onRemove} />
|
||||
<BarButton icon={allSelected ? ListX : ListChecks} text={allSelected ? '取消全选' : '全选'} label={allSelected ? '取消全选' : '全选'} onClick={onToggleAll} />
|
||||
<IconButton icon={X} className="playlist-bar-close" label="退出选择" onClick={onClose} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TrackEdits } from './track-edits'
|
||||
|
||||
// 由测试决定每个请求何时完成、成功与否。
|
||||
function requests() {
|
||||
const pending: ((ok: boolean) => void)[] = []
|
||||
const sent: string[] = []
|
||||
const request = (name: string) => () => {
|
||||
sent.push(name)
|
||||
return new Promise<boolean>(resolve => pending.push(resolve))
|
||||
}
|
||||
const settle = async (ok: boolean) => {
|
||||
await flush()
|
||||
pending.shift()!(ok)
|
||||
await flush()
|
||||
}
|
||||
return { request, sent, settle }
|
||||
}
|
||||
|
||||
function flush() {
|
||||
return new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function setup() {
|
||||
let rereads = 0
|
||||
const edits = new TrackEdits(() => rereads++)
|
||||
return { edits, rereads: () => rereads }
|
||||
}
|
||||
|
||||
describe('trackEdits', () => {
|
||||
it('没有修改时读到的结果可以用', () => {
|
||||
const { edits } = setup()
|
||||
const usable = edits.read()
|
||||
expect(usable()).toBe(true)
|
||||
})
|
||||
|
||||
it('修改进行中返回的读取不能用', async () => {
|
||||
const { edits } = setup()
|
||||
const { request, settle } = requests()
|
||||
void edits.run(request('移出'), () => {})
|
||||
const usable = edits.read()
|
||||
expect(usable()).toBe(false)
|
||||
await settle(true)
|
||||
expect(usable()).toBe(false)
|
||||
})
|
||||
|
||||
it('读取开始之后发起的修改,即使在读取返回前已经完成,读到的也不能用', async () => {
|
||||
const { edits } = setup()
|
||||
const { request, settle } = requests()
|
||||
const usable = edits.read()
|
||||
void edits.run(request('移动'), () => {})
|
||||
await settle(true)
|
||||
expect(usable()).toBe(false)
|
||||
})
|
||||
|
||||
it('最后一个修改完成之后重新读取一次', async () => {
|
||||
const { edits, rereads } = setup()
|
||||
const { request, settle } = requests()
|
||||
void edits.run(request('移出'), () => {})
|
||||
void edits.run(request('移动'), () => {})
|
||||
await settle(true)
|
||||
expect(rereads()).toBe(0)
|
||||
await settle(true)
|
||||
expect(rereads()).toBe(1)
|
||||
expect(edits.read()()).toBe(true)
|
||||
})
|
||||
|
||||
it('修改按发起的先后逐个请求,前一个完成之后才发下一个', async () => {
|
||||
const { edits } = setup()
|
||||
const { request, sent, settle } = requests()
|
||||
void edits.run(request('移到最前'), () => {})
|
||||
void edits.run(request('移到最后'), () => {})
|
||||
await flush()
|
||||
expect(sent).toEqual(['移到最前'])
|
||||
await settle(true)
|
||||
expect(sent).toEqual(['移到最前', '移到最后'])
|
||||
})
|
||||
|
||||
it('一次失败之后,排在后面的不再发出,列表回到失败的那次之前,结束后重新读取一次', async () => {
|
||||
const { edits, rereads } = setup()
|
||||
const { request, sent, settle } = requests()
|
||||
const rolledBack: string[] = []
|
||||
let shown = '第一次移动之后'
|
||||
void edits.run(request('第一次移动'), () => {
|
||||
rolledBack.push('第一次移动')
|
||||
shown = '移动之前'
|
||||
})
|
||||
shown = '第二次移动之后'
|
||||
const done = edits.run(request('第二次移动'), () => {
|
||||
rolledBack.push('第二次移动')
|
||||
shown = '第一次移动之后'
|
||||
})
|
||||
await settle(false)
|
||||
await done
|
||||
expect(sent).toEqual(['第一次移动'])
|
||||
expect(rolledBack).toEqual(['第一次移动'])
|
||||
expect(shown).toBe('移动之前')
|
||||
expect(rereads()).toBe(1)
|
||||
})
|
||||
|
||||
it('失败之后新发起的修改照常请求', async () => {
|
||||
const { edits } = setup()
|
||||
const { request, sent, settle } = requests()
|
||||
void edits.run(request('移动'), () => {})
|
||||
await settle(false)
|
||||
void edits.run(request('移出'), () => {})
|
||||
await flush()
|
||||
expect(sent).toEqual(['移动', '移出'])
|
||||
})
|
||||
|
||||
it('移出失败时回到移出之前,全部完成之后重新读取', async () => {
|
||||
const { edits, rereads } = setup()
|
||||
const { request, settle } = requests()
|
||||
let items = ['晴天', '七里香']
|
||||
const before = items
|
||||
items = ['七里香']
|
||||
const done = edits.run(request('移出'), () => {
|
||||
items = before
|
||||
})
|
||||
await settle(false)
|
||||
await done
|
||||
expect(items).toEqual(['晴天', '七里香'])
|
||||
expect(rereads()).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
// 详情页对曲目的修改(移动、移出)先在本地改好再请求。请求按发起的先后逐个发出,每次都基于上一次修改之后的顺序;
|
||||
// 一次失败之后,排在它后面的不再发出,由失败的那次回到它之前的样子。
|
||||
// 修改进行中读到的列表可能是修改之前的,不能用;最后一个修改结束之后再读一次,以服务端为准。
|
||||
export class TrackEdits {
|
||||
readonly #reread: () => void
|
||||
#tail: Promise<void> = Promise.resolve()
|
||||
#generation = 0
|
||||
#pending = 0
|
||||
#started = 0
|
||||
|
||||
constructor(reread: () => void) {
|
||||
this.#reread = reread
|
||||
}
|
||||
|
||||
// request 失败时返回 false,不抛出。
|
||||
run(request: () => Promise<boolean>, rollback: () => void): Promise<void> {
|
||||
const generation = this.#generation
|
||||
this.#pending++
|
||||
this.#started++
|
||||
const done = this.#tail.then(async () => {
|
||||
if (this.#generation !== generation || await request())
|
||||
return
|
||||
this.#generation++
|
||||
rollback()
|
||||
}).finally(() => {
|
||||
if (--this.#pending === 0)
|
||||
this.#reread()
|
||||
})
|
||||
this.#tail = done
|
||||
return done
|
||||
}
|
||||
|
||||
// 读取开始时调用,返回的函数在读取返回时判断读到的列表能不能用。
|
||||
read(): () => boolean {
|
||||
const idle = this.#pending === 0
|
||||
const started = this.#started
|
||||
return () => idle && this.#started === started
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dropAt, moveByOne, moveTracks } from './track-order'
|
||||
|
||||
const order = ['a', 'b', 'c', 'd', 'e']
|
||||
|
||||
// 与 internal/domain/playlist/order_test.go 的 TestMove 逐条对应。
|
||||
describe('moveTracks', () => {
|
||||
const cases: { name: string, moved: string[], before?: string, want?: string[], error?: string }[] = [
|
||||
{ name: '移到最前', moved: ['d'], before: 'a', want: ['d', 'a', 'b', 'c', 'e'] },
|
||||
{ name: '移到最后', moved: ['b'], want: ['a', 'c', 'd', 'e', 'b'] },
|
||||
{ name: '移到中间', moved: ['a'], before: 'd', want: ['b', 'c', 'a', 'd', 'e'] },
|
||||
{ name: '多首保持给定先后', moved: ['e', 'b'], before: 'd', want: ['a', 'c', 'e', 'b', 'd'] },
|
||||
{ name: '多首移到最后保持给定先后', moved: ['c', 'a'], want: ['b', 'd', 'e', 'c', 'a'] },
|
||||
{ name: '忽略不存在的歌', moved: ['x', 'c', 'y'], before: 'a', want: ['c', 'a', 'b', 'd', 'e'] },
|
||||
{ name: '重复的歌只算一次', moved: ['c', 'c', 'b'], want: ['a', 'd', 'e', 'c', 'b'] },
|
||||
{ name: 'before 紧跟在移动的歌之后', moved: ['b'], before: 'c', want: order },
|
||||
{ name: 'before 是被移动的歌', moved: ['b', 'c'], before: 'c', error: 'before 不能是要移动的歌' },
|
||||
{ name: 'before 是不存在的被移动的歌', moved: ['x'], before: 'x', error: 'before 不能是要移动的歌' },
|
||||
{ name: 'before 不存在', moved: ['b'], before: 'x', error: '歌单已经变了,刷新之后再试' },
|
||||
{ name: '空移动', moved: [], before: 'c', want: order },
|
||||
{ name: '移动的歌都不存在', moved: ['x'], before: 'y', want: order },
|
||||
]
|
||||
for (const { name, moved, before, want, error } of cases) {
|
||||
it(name, () => {
|
||||
const copy = [...order]
|
||||
if (error)
|
||||
expect(() => moveTracks(copy, moved, before)).toThrow(error)
|
||||
else
|
||||
expect(moveTracks(copy, moved, before)).toEqual(want)
|
||||
expect(copy).toEqual(order)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('dropAt', () => {
|
||||
it('插入位置之后第一首不移动的歌作为 before', () => {
|
||||
expect(dropAt(order, new Set(['d']), 1)).toEqual({ order: ['a', 'd', 'b', 'c', 'e'], moved: ['d'], before: 'b' })
|
||||
})
|
||||
|
||||
it('插入位置紧跟着要移动的歌时,跳过它们找 before', () => {
|
||||
expect(dropAt(order, new Set(['b', 'e']), 1)).toEqual({ order: ['a', 'b', 'e', 'c', 'd'], moved: ['b', 'e'], before: 'c' })
|
||||
})
|
||||
|
||||
it('放到最后时不传 before', () => {
|
||||
expect(dropAt(order, new Set(['a', 'c']), 5)).toEqual({ order: ['b', 'd', 'e', 'a', 'c'], moved: ['a', 'c'], before: undefined })
|
||||
})
|
||||
|
||||
it('多首按当前顺序一起移动', () => {
|
||||
expect(dropAt(order, new Set(['e', 'b']), 0)).toEqual({ order: ['b', 'e', 'a', 'c', 'd'], moved: ['b', 'e'], before: 'a' })
|
||||
})
|
||||
|
||||
it('拖回原位置不必发请求', () => {
|
||||
expect(dropAt(order, new Set(['c']), 2)).toBeNull()
|
||||
expect(dropAt(order, new Set(['c']), 3)).toBeNull()
|
||||
expect(dropAt(order, new Set(['b', 'c']), 2)).toBeNull()
|
||||
})
|
||||
|
||||
it('全选时什么也不做', () => {
|
||||
expect(dropAt(order, new Set(order), 0)).toBeNull()
|
||||
expect(dropAt(order, new Set(order), 5)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveByOne', () => {
|
||||
it('上移和下移一首', () => {
|
||||
expect(moveByOne(order, new Set(['c']), -1)).toEqual({ order: ['a', 'c', 'b', 'd', 'e'], moved: ['c'], before: 'b' })
|
||||
expect(moveByOne(order, new Set(['c']), 1)).toEqual({ order: ['a', 'b', 'd', 'c', 'e'], moved: ['c'], before: 'e' })
|
||||
})
|
||||
|
||||
it('下移到最后时不传 before', () => {
|
||||
expect(moveByOne(order, new Set(['d']), 1)).toEqual({ order: ['a', 'b', 'c', 'e', 'd'], moved: ['d'], before: undefined })
|
||||
})
|
||||
|
||||
it('相连的多首一起移动', () => {
|
||||
expect(moveByOne(order, new Set(['b', 'c']), -1)).toEqual({ order: ['b', 'c', 'a', 'd', 'e'], moved: ['b', 'c'], before: 'a' })
|
||||
expect(moveByOne(order, new Set(['b', 'c']), 1)).toEqual({ order: ['a', 'd', 'b', 'c', 'e'], moved: ['b', 'c'], before: 'e' })
|
||||
})
|
||||
|
||||
it('不相连的多首先收拢', () => {
|
||||
expect(moveByOne(order, new Set(['b', 'd']), -1)).toEqual({ order: ['b', 'd', 'a', 'c', 'e'], moved: ['b', 'd'], before: 'a' })
|
||||
expect(moveByOne(order, new Set(['b', 'd']), 1)).toEqual({ order: ['a', 'c', 'e', 'b', 'd'], moved: ['b', 'd'], before: undefined })
|
||||
})
|
||||
|
||||
it('已经在最前时上移只收拢', () => {
|
||||
expect(moveByOne(order, new Set(['a', 'c']), -1)).toEqual({ order: ['a', 'c', 'b', 'd', 'e'], moved: ['a', 'c'], before: 'b' })
|
||||
})
|
||||
|
||||
it('已经到头时不动', () => {
|
||||
expect(moveByOne(order, new Set(['a', 'b']), -1)).toBeNull()
|
||||
expect(moveByOne(order, new Set(['e']), 1)).toBeNull()
|
||||
expect(moveByOne(order, new Set(order), 1)).toBeNull()
|
||||
})
|
||||
|
||||
it('要移动的歌都不在列表里时不动', () => {
|
||||
expect(moveByOne(order, new Set(['x']), -1)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// 与后端的 playlist.Move 一致:order 是显示顺序,把 moved 按给出的先后放到 before 之前,没有 before 时放到最后。
|
||||
// moved 里不在 order 中的忽略,重复的只算一次;都不在 order 中时原样返回,此时不检查 before 是否还在。
|
||||
export function moveTracks<T>(order: readonly T[], moved: readonly T[], before?: T): T[] {
|
||||
if (before !== undefined && moved.includes(before))
|
||||
throw new Error('before 不能是要移动的歌')
|
||||
const present = new Set(order)
|
||||
const block = [...new Set(moved)].filter(item => present.has(item))
|
||||
if (block.length === 0)
|
||||
return [...order]
|
||||
const blocked = new Set(block)
|
||||
const rest = order.filter(item => !blocked.has(item))
|
||||
const at = before === undefined ? rest.length : rest.indexOf(before)
|
||||
if (at < 0)
|
||||
throw new Error('歌单已经变了,刷新之后再试')
|
||||
return [...rest.slice(0, at), ...block, ...rest.slice(at)]
|
||||
}
|
||||
|
||||
// 一次移动请求:把 moved 按 order 中的先后放到 before 之前,结果是 order。
|
||||
export interface MovePlan<T> {
|
||||
order: T[]
|
||||
moved: T[]
|
||||
before: T | undefined
|
||||
}
|
||||
|
||||
// 把 moved 收拢成一段,放在其余的歌中第 at 首之前;顺序不变时返回 null,不必发请求。
|
||||
function place<T>(order: readonly T[], moved: ReadonlySet<T>, at: number): MovePlan<T> | null {
|
||||
const block = order.filter(item => moved.has(item))
|
||||
const before = order.filter(item => !moved.has(item))[at]
|
||||
const next = moveTracks(order, block, before)
|
||||
if (next.every((item, index) => item === order[index]))
|
||||
return null
|
||||
return { order: next, moved: block, before }
|
||||
}
|
||||
|
||||
// index 是插入位置,取 0 到 order.length,表示放在 order[index] 之前。before 取插入位置之后第一首不移动的歌。
|
||||
export function dropAt<T>(order: readonly T[], moved: ReadonlySet<T>, index: number): MovePlan<T> | null {
|
||||
return place(order, moved, order.slice(0, index).filter(item => !moved.has(item)).length)
|
||||
}
|
||||
|
||||
// 上移时整段放到第一首要移动的歌前面那首之前,下移时放到最后一首要移动的歌后面那首之后。
|
||||
// 不相连的歌会先收拢到一起,与拖动多首时一致,所以一次请求就能完成。
|
||||
export function moveByOne<T>(order: readonly T[], moved: ReadonlySet<T>, delta: -1 | 1): MovePlan<T> | null {
|
||||
const indexes = order.flatMap((item, index) => moved.has(item) ? [index] : [])
|
||||
if (indexes.length === 0)
|
||||
return null
|
||||
const edge = delta < 0 ? indexes[0] : indexes[indexes.length - 1]
|
||||
const restBefore = edge - indexes.filter(index => index < edge).length
|
||||
const restCount = order.length - indexes.length
|
||||
return place(order, moved, Math.min(restCount, Math.max(0, restBefore + delta)))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Selection } from './track-selection'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { noSelection, retain, selectAll, selectRange, toggle } from './track-selection'
|
||||
|
||||
const order = ['a', 'b', 'c', 'd', 'e']
|
||||
|
||||
function keys(selection: Selection): string[] {
|
||||
return [...selection.keys].sort()
|
||||
}
|
||||
|
||||
describe('track-selection', () => {
|
||||
it('单击和 ⌘ 点击逐首切换,并把这首作为锚点', () => {
|
||||
const one = toggle(noSelection, 'b')
|
||||
expect(keys(one)).toEqual(['b'])
|
||||
expect(one.anchor).toBe('b')
|
||||
const two = toggle(one, 'd')
|
||||
expect(keys(two)).toEqual(['b', 'd'])
|
||||
const back = toggle(two, 'b')
|
||||
expect(keys(back)).toEqual(['d'])
|
||||
expect(back.anchor).toBe('b')
|
||||
})
|
||||
|
||||
it('shift 点击把锚点到这首的一段加入选择,锚点不变', () => {
|
||||
const selection = selectRange(toggle(toggle(noSelection, 'e'), 'b'), order, 'd')
|
||||
expect(keys(selection)).toEqual(['b', 'c', 'd', 'e'])
|
||||
expect(selection.anchor).toBe('b')
|
||||
})
|
||||
|
||||
it('shift 点击锚点之前的歌时向上选', () => {
|
||||
expect(keys(selectRange(toggle(noSelection, 'd'), order, 'b'))).toEqual(['b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('没有锚点时 shift 点击只切换这首', () => {
|
||||
const selection = selectRange(noSelection, order, 'c')
|
||||
expect(keys(selection)).toEqual(['c'])
|
||||
expect(selection.anchor).toBe('c')
|
||||
})
|
||||
|
||||
it('锚点已经不在列表里时 shift 点击只切换这首', () => {
|
||||
expect(keys(selectRange(toggle(noSelection, 'x'), order, 'c'))).toEqual(['c', 'x'])
|
||||
})
|
||||
|
||||
it('全选和清空', () => {
|
||||
expect(keys(selectAll(order))).toEqual(order)
|
||||
expect(keys(noSelection)).toEqual([])
|
||||
})
|
||||
|
||||
it('列表变化后去掉不存在的歌和锚点', () => {
|
||||
const selection = retain(toggle(toggle(noSelection, 'a'), 'c'), ['a', 'b'])
|
||||
expect(keys(selection)).toEqual(['a'])
|
||||
expect(selection.anchor).toBeNull()
|
||||
})
|
||||
|
||||
it('列表变化不影响选择时返回原对象', () => {
|
||||
const selection = toggle(noSelection, 'c')
|
||||
expect(retain(selection, order)).toBe(selection)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface Selection {
|
||||
keys: ReadonlySet<string>
|
||||
// Shift 点击从这里选起:最后一次逐首切换的那首。
|
||||
anchor: string | null
|
||||
}
|
||||
|
||||
export const noSelection: Selection = { keys: new Set(), anchor: null }
|
||||
|
||||
export function toggle(selection: Selection, key: string): Selection {
|
||||
const keys = new Set(selection.keys)
|
||||
if (!keys.delete(key))
|
||||
keys.add(key)
|
||||
return { keys, anchor: key }
|
||||
}
|
||||
|
||||
// 把锚点到 key 的一段加入选择;没有锚点,或者锚点已经不在列表里时,只切换 key。
|
||||
export function selectRange(selection: Selection, order: readonly string[], key: string): Selection {
|
||||
const from = selection.anchor === null ? -1 : order.indexOf(selection.anchor)
|
||||
const to = order.indexOf(key)
|
||||
if (from < 0 || to < 0)
|
||||
return toggle(selection, key)
|
||||
const range = order.slice(Math.min(from, to), Math.max(from, to) + 1)
|
||||
return { keys: new Set([...selection.keys, ...range]), anchor: selection.anchor }
|
||||
}
|
||||
|
||||
export function selectAll(order: readonly string[]): Selection {
|
||||
return { keys: new Set(order), anchor: null }
|
||||
}
|
||||
|
||||
// 列表重新读取之后去掉已经不在里面的歌;没有变化时返回原对象。
|
||||
export function retain(selection: Selection, order: readonly string[]): Selection {
|
||||
const present = new Set(order)
|
||||
const keys = [...selection.keys].filter(key => present.has(key))
|
||||
const anchor = selection.anchor !== null && present.has(selection.anchor) ? selection.anchor : null
|
||||
if (keys.length === selection.keys.size && anchor === selection.anchor)
|
||||
return selection
|
||||
return { keys: new Set(keys), anchor }
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'
|
||||
import type { MovePlan } from './track-order'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { dropAt } from './track-order'
|
||||
|
||||
// 按下之后移动超过这个距离才算拖动,否则是点击。
|
||||
const dragThreshold = 4
|
||||
const scrollZone = 56
|
||||
const maxScrollStep = 18
|
||||
|
||||
function scrollParent(element: HTMLElement): HTMLElement {
|
||||
for (let node = element.parentElement; node; node = node.parentElement) {
|
||||
const { overflowY } = getComputedStyle(node)
|
||||
if (overflowY === 'auto' || overflowY === 'scroll')
|
||||
return node
|
||||
}
|
||||
return document.documentElement
|
||||
}
|
||||
|
||||
// 插入位置:指针在第几行的上半部分之上,取 0 到行数。
|
||||
function insertionIndex(rows: readonly HTMLElement[], y: number): number {
|
||||
let low = 0
|
||||
let high = rows.length
|
||||
while (low < high) {
|
||||
const middle = (low + high) >> 1
|
||||
const rect = rows[middle].getBoundingClientRect()
|
||||
if (y < rect.top + rect.height / 2)
|
||||
high = middle
|
||||
else
|
||||
low = middle + 1
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
// 拖动结束时,按下和松开的位置不在同一行,浏览器仍会派发 click:吞掉它,免得切换选中。
|
||||
function swallowNextClick(): void {
|
||||
const swallow = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
}
|
||||
window.addEventListener('click', swallow, { capture: true, once: true })
|
||||
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0)
|
||||
}
|
||||
|
||||
// 鼠标按住行的任意位置都能拖,触屏只能拖把手,行的其余部分留给滚动。
|
||||
// 插入线和指针旁的标签每一帧直接改样式,不经过 React,拖动时整张列表不会重新渲染。
|
||||
export function useTrackDrag({ listRef, lineRef, labelRef, keys, selected, onDrop }: {
|
||||
listRef: RefObject<HTMLElement | null>
|
||||
lineRef: RefObject<HTMLElement | null>
|
||||
labelRef: RefObject<HTMLElement | null>
|
||||
keys: readonly string[]
|
||||
selected: ReadonlySet<string>
|
||||
onDrop: (plan: MovePlan<string> | null) => void
|
||||
}) {
|
||||
const [dragging, setDragging] = useState<ReadonlySet<string> | null>(null)
|
||||
const latestRef = useRef({ keys, selected, onDrop })
|
||||
useLayoutEffect(() => {
|
||||
latestRef.current = { keys, selected, onDrop }
|
||||
})
|
||||
const stopRef = useRef<(() => void) | null>(null)
|
||||
useEffect(() => () => stopRef.current?.(), [])
|
||||
|
||||
const onPointerDown = (event: ReactPointerEvent<HTMLElement>, key: string) => {
|
||||
const target = event.target as Element
|
||||
if (event.button !== 0 || stopRef.current || target.closest('button, a'))
|
||||
return
|
||||
if (event.pointerType !== 'mouse' && !target.closest('[data-drag-handle]'))
|
||||
return
|
||||
const list = listRef.current
|
||||
if (!list)
|
||||
return
|
||||
const { pointerId } = event
|
||||
const scroller = scrollParent(list)
|
||||
const start = { x: event.clientX, y: event.clientY }
|
||||
const pointer = { ...start }
|
||||
let moved: ReadonlySet<string> | null = null
|
||||
let frame = 0
|
||||
|
||||
const rows = () => [...list.querySelectorAll<HTMLElement>('[data-key]')]
|
||||
|
||||
const autoScroll = () => {
|
||||
const rect = scroller === document.documentElement ? new DOMRect(0, 0, innerWidth, innerHeight) : scroller.getBoundingClientRect()
|
||||
const top = Math.max(rect.top, 0)
|
||||
const bottom = Math.min(rect.bottom, innerHeight)
|
||||
const into = pointer.y < top + scrollZone ? pointer.y - top - scrollZone : pointer.y > bottom - scrollZone ? pointer.y - bottom + scrollZone : 0
|
||||
if (into !== 0)
|
||||
scroller.scrollTop += Math.max(-1, Math.min(1, into / scrollZone)) * maxScrollStep
|
||||
}
|
||||
|
||||
const paint = () => {
|
||||
autoScroll()
|
||||
const all = rows()
|
||||
const index = insertionIndex(all, pointer.y)
|
||||
const line = lineRef.current
|
||||
if (line && all.length > 0) {
|
||||
const plan = moved && dropAt(latestRef.current.keys, moved, index)
|
||||
const edge = index < all.length ? all[index].getBoundingClientRect().top : all[all.length - 1].getBoundingClientRect().bottom
|
||||
line.style.transform = `translateY(${edge - list.getBoundingClientRect().top}px)`
|
||||
line.style.visibility = plan ? 'visible' : 'hidden'
|
||||
}
|
||||
const label = labelRef.current
|
||||
if (label) {
|
||||
label.style.transform = `translate(${pointer.x + 14}px, ${pointer.y + 12}px)`
|
||||
label.style.visibility = 'visible'
|
||||
}
|
||||
frame = requestAnimationFrame(paint)
|
||||
}
|
||||
|
||||
function onMove(move: PointerEvent) {
|
||||
if (move.pointerId !== pointerId)
|
||||
return
|
||||
pointer.x = move.clientX
|
||||
pointer.y = move.clientY
|
||||
if (moved || Math.hypot(pointer.x - start.x, pointer.y - start.y) <= dragThreshold)
|
||||
return
|
||||
const { selected } = latestRef.current
|
||||
moved = selected.has(key) ? selected : new Set([key])
|
||||
setDragging(moved)
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
frame = requestAnimationFrame(paint)
|
||||
}
|
||||
|
||||
function endVisuals() {
|
||||
cancelAnimationFrame(frame)
|
||||
window.removeEventListener('keydown', onKey, true)
|
||||
setDragging(null)
|
||||
}
|
||||
|
||||
function onUp(up: PointerEvent) {
|
||||
if (up.pointerId !== pointerId)
|
||||
return
|
||||
const dropped = moved
|
||||
stop()
|
||||
if (!dropped)
|
||||
return
|
||||
swallowNextClick()
|
||||
latestRef.current.onDrop(dropAt(latestRef.current.keys, dropped, insertionIndex(rows(), up.clientY)))
|
||||
}
|
||||
|
||||
function onCancel(cancel: PointerEvent) {
|
||||
if (cancel.pointerId === pointerId)
|
||||
stop()
|
||||
}
|
||||
|
||||
// 按 Esc 取消之后指针仍然按着,松开时的 click 同样要吞掉。
|
||||
function onKey(keyboard: KeyboardEvent) {
|
||||
if (keyboard.key !== 'Escape')
|
||||
return
|
||||
keyboard.preventDefault()
|
||||
keyboard.stopPropagation()
|
||||
endVisuals()
|
||||
document.removeEventListener('pointermove', onMove)
|
||||
document.removeEventListener('pointerup', onUp)
|
||||
document.addEventListener('pointerup', onCancelledUp)
|
||||
}
|
||||
|
||||
function onCancelledUp(up: PointerEvent) {
|
||||
if (up.pointerId !== pointerId)
|
||||
return
|
||||
stop()
|
||||
swallowNextClick()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
endVisuals()
|
||||
document.removeEventListener('pointermove', onMove)
|
||||
document.removeEventListener('pointerup', onUp)
|
||||
document.removeEventListener('pointerup', onCancelledUp)
|
||||
document.removeEventListener('pointercancel', onCancel)
|
||||
stopRef.current = null
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', onMove)
|
||||
document.addEventListener('pointerup', onUp)
|
||||
document.addEventListener('pointercancel', onCancel)
|
||||
stopRef.current = stop
|
||||
}
|
||||
|
||||
return { dragging, onPointerDown }
|
||||
}
|
||||
Reference in new issue
Block a user