feat: select several playlist songs to remove, save or reorder them and drag songs into place
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s

This commit is contained in:
DiheChen committed 2026-11-09 21:33:06 +08:00
1 parent d73a161e84
commit e539c7d78b
13 files changed
+1078 -65

No files matched your search

+163 -55
View File
@@ -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 { EmptyState, Modal, 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,34 +36,83 @@ 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] = useState(Date.now)
const name = playlist?.name ?? '歌单'
// 读到了自己的歌单,里面却没有它:在别处删掉了,或者换了账号。
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 = (
@@ -95,57 +154,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}>
@@ -164,12 +219,65 @@ 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 && (
<Modal title="移出歌曲" onClose={() => setConfirmingRemoval(false)}>
<p className="text-ui-base muted">{`把 ${selected.length} 首歌移出这个歌单?`}</p>
<div className="dialog-actions">
<button type="button" className="button-secondary" onClick={() => setConfirmingRemoval(false)}>取消</button>
<button
type="button"
className="button-destructive"
onClick={() => {
setConfirmingRemoval(false)
void remove(playlist, shown, selected)
}}
>
移出
</button>
</div>
</Modal>
)}
</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>
)
}
@@ -382,6 +382,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()
+35 -1
View File
@@ -107,9 +107,39 @@
.my-playlist-info { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
.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 { 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; }
@@ -120,6 +150,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 failed = useStore(playlistsStore, s => s.failed)
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 = (
@@ -120,7 +154,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}>
@@ -128,7 +162,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" />
@@ -141,12 +176,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)
})
})
+39
View File
@@ -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()
})
})
+50
View File
@@ -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 }
}