feat(providers): add persistent card view and shared drag ordering

This commit is contained in:
elky
2026-09-07 14:13:59 +08:00
parent a26680f460
commit aa7dbe67d3
10 changed files with 1280 additions and 9 deletions
@@ -0,0 +1,323 @@
<template>
<Card
variant="interactive"
class="flex min-w-0 flex-col cursor-pointer overflow-hidden"
@mousedown="$emit('mousedown', $event)"
@click="$emit('rowClick', $event, provider.id)"
>
<div class="flex items-start gap-2 p-4 pb-3">
<slot name="drag-handle" />
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-base font-semibold"
:class="provider.is_active ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'"
aria-hidden="true"
>
{{ provider.name.slice(0, 1).toUpperCase() }}
</div>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex items-center gap-1.5">
<button
type="button"
class="min-w-0 truncate rounded text-left text-sm font-semibold text-foreground hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:title="provider.name"
@click.stop="$emit('viewDetail', provider.id)"
>
{{ provider.name }}
</button>
<a
v-if="safeProviderWebsite"
:href="safeProviderWebsite"
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-muted-foreground transition-colors hover:text-primary"
:title="safeProviderWebsite"
@click.stop
>
<ExternalLink class="h-3.5 w-3.5" />
</a>
</div>
<div
v-if="editingDescriptionId === provider.id"
data-desc-editor
class="flex items-center gap-1"
@click.stop
>
<input
v-model="localDescriptionValue"
v-auto-focus
class="min-w-0 flex-1 rounded border border-border bg-background px-1.5 py-0.5 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
:placeholder="legacyT('输入备注...')"
:aria-label="legacyT('输入备注...')"
@keydown="handleDescriptionKeydown"
>
<button
type="button"
class="shrink-0 rounded p-0.5 text-primary hover:bg-muted"
:title="legacyT('保存')"
:aria-label="legacyT('保存')"
@click="handleSave"
>
<Check class="h-3.5 w-3.5" />
</button>
<button
type="button"
class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted"
:title="legacyT('取消')"
:aria-label="legacyT('取消')"
@click="handleCancel"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
<button
v-else
type="button"
class="group/desc flex max-w-full items-center gap-1 rounded text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:title="provider.description || legacyT('添加备注')"
@click="handleStartEdit"
>
<span class="truncate">{{ provider.description || legacyT('添加备注') }}</span>
<Pencil class="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover/desc:opacity-60 group-focus-visible/desc:opacity-60" />
</button>
</div>
<Badge
:variant="provider.is_active ? 'success' : 'secondary'"
class="shrink-0 text-xs"
>
{{ legacyT(provider.is_active ? '活跃' : '停用') }}
</Badge>
</div>
<div class="flex flex-1 flex-col gap-4 px-4 pb-4">
<div class="space-y-2 rounded-xl border border-border/40 bg-muted/20 p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ legacyT('余额监控') }}</span>
<Badge
variant="outline"
class="border-border/50 text-[10px] font-normal"
>
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
</div>
<ProviderBalanceCell
:provider="provider"
:is-balance-loading="isBalanceLoading"
:get-provider-balance="getProviderBalance"
:get-provider-balance-breakdown="getProviderBalanceBreakdown"
:get-provider-balance-error="getProviderBalanceError"
:get-provider-checkin="getProviderCheckin"
:get-provider-cookie-expired="getProviderCookieExpired"
:get-provider-balance-extra="getProviderBalanceExtra"
:format-balance-display="formatBalanceDisplay"
:format-reset-countdown="formatResetCountdown"
:get-quota-used-color-class="getQuotaUsedColorClass"
/>
</div>
<dl class="grid grid-cols-3 divide-x divide-border/50 text-center">
<div class="min-w-0 space-y-1 px-1">
<dt class="text-xs text-muted-foreground">
{{ legacyT('端点') }}
</dt>
<dd class="text-sm font-semibold tabular-nums">
{{ provider.active_endpoints }}<span class="ml-0.5 text-xs font-normal text-muted-foreground">/ {{ provider.total_endpoints }}</span>
</dd>
</div>
<div class="min-w-0 space-y-1 px-1">
<dt class="text-xs text-muted-foreground">
{{ legacyT(isKeyManagedProviderType(provider.provider_type) ? '密钥' : '账号') }}
</dt>
<dd class="text-sm font-semibold tabular-nums">
{{ provider.active_keys }}<span class="ml-0.5 text-xs font-normal text-muted-foreground">/ {{ provider.total_keys }}</span>
</dd>
</div>
<div class="min-w-0 space-y-1 px-1">
<dt class="text-xs text-muted-foreground">
{{ legacyT('模型') }}
</dt>
<dd class="text-sm font-semibold tabular-nums">
{{ provider.active_models }}<span class="ml-0.5 text-xs font-normal text-muted-foreground">/ {{ provider.total_models }}</span>
</dd>
</div>
</dl>
<div class="mt-auto space-y-2 border-t border-border/40 pt-3">
<div class="text-xs text-muted-foreground">
{{ legacyT('端点健康') }}
</div>
<div
v-if="provider.endpoint_health_details?.length"
class="grid grid-cols-3 gap-x-3 gap-y-2"
>
<div
v-for="endpoint in sortEndpoints(provider.endpoint_health_details)"
:key="endpoint.api_format"
class="flex min-w-0 flex-col gap-1.5"
:title="getEndpointTooltip(endpoint, locale)"
>
<div class="flex items-center justify-between gap-1 text-[10px] leading-none text-muted-foreground">
<span class="font-medium">{{ formatApiFormatShort(endpoint.api_format) }}</span>
<span class="tabular-nums">{{ getEndpointHealthLabel(endpoint) }}</span>
</div>
<div class="h-1.5 w-full overflow-hidden rounded-full bg-border dark:bg-border/80">
<div
class="h-full rounded-full transition-all duration-300"
:class="getEndpointDotColor(endpoint)"
:style="{ width: getEndpointHealthBarWidth(endpoint) }"
/>
</div>
</div>
</div>
<span
v-else
class="text-xs text-muted-foreground/60"
>{{ legacyT('暂无端点') }}</span>
</div>
</div>
<div
class="flex items-center justify-between gap-2 border-t border-border/40 bg-muted/10 px-3 py-2"
@click.stop
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-primary"
:title="legacyT('查看详情')"
:aria-label="legacyT('查看详情')"
@click="$emit('viewDetail', provider.id)"
>
<Eye class="h-3.5 w-3.5" />
</Button>
<div class="flex shrink-0 items-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
:title="legacyT('编辑提供商')"
:aria-label="legacyT('编辑提供商')"
@click="$emit('editProvider', provider)"
>
<Edit class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
:title="legacyT('扩展操作配置')"
:aria-label="legacyT('扩展操作配置')"
@click="$emit('openOpsConfig', provider)"
>
<KeyRound class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
:title="legacyT(provider.is_active ? '停用提供商' : '启用提供商')"
:aria-label="legacyT(provider.is_active ? '停用提供商' : '启用提供商')"
@click="$emit('toggleStatus', provider)"
>
<Power class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-destructive"
:title="legacyT('删除提供商')"
:aria-label="legacyT('删除提供商')"
@click="$emit('deleteProvider', provider)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Check, Edit, ExternalLink, Eye, KeyRound, Pencil, Power, Trash2, X } from 'lucide-vue-next'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
import Card from '@/components/ui/card.vue'
import ProviderBalanceCell from './ProviderBalanceCell.vue'
import { formatApiFormatShort, type ProviderWithEndpointsSummary } from '@/api/endpoints'
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
import {
sortEndpoints,
getEndpointHealthLabel,
getEndpointHealthBarWidth,
getEndpointDotColor,
getEndpointTooltip,
} from '@/features/providers/composables/useEndpointStatus'
import { isKeyManagedProviderType } from '../utils/providerTypeUtils'
import { formatBillingType } from '@/utils/format'
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
import { useI18n } from '@/i18n'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
editingDescriptionId: string | null
isBalanceLoading: (providerId: string) => boolean
getProviderBalance: (providerId: string) => { available: number | null; currency: string } | null
getProviderBalanceBreakdown: (providerId: string) => { balance: number; points: number; currency: string } | null
getProviderBalanceError: (providerId: string) => { status: string; message: string } | null
getProviderCheckin: (providerId: string) => { success: boolean | null; message: string } | null
getProviderCookieExpired: (providerId: string) => { expired: boolean; message: string } | null
getProviderBalanceExtra: (providerId: string, architectureId?: string) => BalanceExtraItem[]
formatBalanceDisplay: (balance: { available: number | null; currency: string } | null) => string
formatResetCountdown: (resetsAt: number) => string
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
}>()
const emit = defineEmits<{
'mousedown': [event: MouseEvent]
'rowClick': [event: MouseEvent, providerId: string]
'viewDetail': [providerId: string]
'editProvider': [provider: ProviderWithEndpointsSummary]
'openOpsConfig': [provider: ProviderWithEndpointsSummary]
'toggleStatus': [provider: ProviderWithEndpointsSummary]
'deleteProvider': [provider: ProviderWithEndpointsSummary]
'startEditDescription': [event: Event, provider: ProviderWithEndpointsSummary]
'saveDescription': [event: Event, provider: ProviderWithEndpointsSummary, value: string]
'cancelEditDescription': [event?: Event]
}>()
const { legacyT, locale } = useI18n()
const safeProviderWebsite = computed(() => safeExternalWebUrl(props.provider.website))
const localDescriptionValue = ref('')
const vAutoFocus = {
mounted: (element: HTMLElement) => element.focus(),
}
watch(() => props.editingDescriptionId, (providerId) => {
if (providerId === props.provider.id) {
localDescriptionValue.value = props.provider.description || ''
}
}, { immediate: true })
function handleStartEdit(event: Event) {
event.stopPropagation()
emit('startEditDescription', event, props.provider)
}
function handleSave(event: Event) {
event.stopPropagation()
emit('saveDescription', event, props.provider, localDescriptionValue.value)
}
function handleCancel(event: Event) {
event.stopPropagation()
emit('cancelEditDescription', event)
}
function handleDescriptionKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
handleSave(event)
} else if (event.key === 'Escape') {
handleCancel(event)
}
}
</script>
@@ -0,0 +1,36 @@
<template>
<Button
variant="ghost"
size="icon"
class="h-8 w-5 touch-none select-none cursor-grab text-muted-foreground/50 hover:text-primary active:cursor-grabbing disabled:cursor-default"
:disabled="disabled"
:title="legacyT('拖动调整当前页展示顺序,也可使用方向键移动')"
:aria-label="`${legacyT('调整展示顺序')}: ${providerName}`"
data-provider-drag-handle
@pointerdown.stop="$emit('pointerdown', $event)"
@keydown.stop="$emit('keydown', $event)"
@mousedown.stop
@click.stop
@dragstart.prevent
>
<GripVertical class="h-4 w-4" />
</Button>
</template>
<script setup lang="ts">
import { GripVertical } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import { useI18n } from '@/i18n'
defineProps<{
providerName: string
disabled: boolean
}>()
defineEmits<{
pointerdown: [event: PointerEvent]
keydown: [event: KeyboardEvent]
}>()
const { legacyT } = useI18n()
</script>
@@ -5,6 +5,7 @@
>
<!-- 第一行:名称 + 状态 + 操作 -->
<div class="flex items-start justify-between gap-3">
<slot name="drag-handle" />
<div class="flex-1 min-w-0 space-y-0.5">
<div class="flex items-center gap-1.5">
<span class="font-medium text-foreground truncate">{{ provider.name }}</span>
@@ -22,7 +22,7 @@
</div>
<!-- 状态筛选 -->
<div class="xl:hidden">
<div :class="{ 'xl:hidden': !cardView }">
<Select
:model-value="filterStatus"
@update:model-value="$emit('update:filterStatus', $event)"
@@ -43,7 +43,7 @@
</div>
<!-- API 格式筛选 -->
<div class="xl:hidden">
<div :class="{ 'xl:hidden': !cardView }">
<Select
:model-value="filterApiFormat"
@update:model-value="$emit('update:filterApiFormat', $event)"
@@ -64,7 +64,7 @@
</div>
<!-- 模型筛选 -->
<div class="xl:hidden">
<div :class="{ 'xl:hidden': !cardView }">
<Select
:model-value="filterModel"
@update:model-value="$emit('update:filterModel', $event)"
@@ -122,13 +122,32 @@
:loading="loading"
@click="$emit('refresh')"
/>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="{ 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary': cardView }"
:title="legacyT(cardView ? '切换到列表视图' : '切换到卡片视图')"
:aria-label="legacyT('卡片视图')"
:aria-pressed="cardView"
@click="$emit('toggleView')"
>
<List
v-if="cardView"
class="w-3.5 h-3.5"
/>
<LayoutGrid
v-else
class="w-3.5 h-3.5"
/>
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Search, Plus, FilterX, Users } from 'lucide-vue-next'
import { Search, Plus, FilterX, Users, LayoutGrid, List } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Select from '@/components/ui/select.vue'
@@ -150,6 +169,7 @@ defineProps<{
modelFilters: FilterOption[]
hasActiveFilters: boolean
loading: boolean
cardView: boolean
}>()
defineEmits<{
@@ -161,6 +181,7 @@ defineEmits<{
'batchProcess': []
'addProvider': []
'refresh': []
'toggleView': []
}>()
const { legacyT } = useI18n()
@@ -4,6 +4,13 @@
@mousedown="$emit('mousedown', $event)"
@click="$emit('rowClick', $event, provider.id)"
>
<TableCell
v-if="$slots['drag-handle']"
class="w-9 px-2 py-3.5"
@click.stop
>
<slot name="drag-handle" />
</TableCell>
<TableCell class="py-3.5">
<div class="space-y-0.5">
<div class="flex items-center gap-1.5">
@@ -5,6 +5,7 @@ import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import { createI18n } from '@/i18n'
import ProviderTableRow from '../ProviderTableRow.vue'
import ProviderMobileCard from '../ProviderMobileCard.vue'
import ProviderCard from '../ProviderCard.vue'
vi.mock('../ProviderBalanceCell.vue', () => ({
default: { render: () => null },
@@ -74,6 +75,7 @@ function mountProvider(component: Component, healthScore: number | null) {
describe.each([
['desktop provider row', ProviderTableRow],
['mobile provider card', ProviderMobileCard],
['grid provider card', ProviderCard],
] as const)('%s endpoint health', (_name, component) => {
it.each([
{ score: null, label: '-', width: '100%', color: 'bg-muted-foreground/40' },
@@ -0,0 +1,224 @@
import { computed, nextTick, onScopeDispose, ref, watch, type Ref } from 'vue'
import { useEventListener, useLocalStorage, useRafFn } from '@vueuse/core'
import { useI18n } from '@/i18n'
interface SortableProvider {
id: string
name: string
}
interface ProviderPointerDrag {
providerId: string
pointerId: number
startX: number
startY: number
handle: HTMLElement
scrollContainer: HTMLElement | null
}
export function useProviderDisplayOrder<Provider extends SortableProvider>(
providers: () => Provider[],
container: Ref<HTMLElement | null>,
) {
const { legacyT } = useI18n()
const savedOrder = useLocalStorage<string[]>('aether-provider-display-order', [])
const knownOrder = ref<string[]>([])
const draggingProviderId = ref<string | null>(null)
const dropTargetId = ref<string | null>(null)
const pointerPosition = ref({ clientX: 0, clientY: 0 })
const announcement = ref('')
let pointerDrag: ProviderPointerDrag | null = null
let suppressClickUntil = 0
const normalizedOrder = computed(() => Array.isArray(savedOrder.value)
? [...new Set(savedOrder.value.filter((providerId): providerId is string => typeof providerId === 'string'))]
: [])
const orderedProviders = computed(() => {
const ranks = new Map(normalizedOrder.value.map((providerId, index) => [providerId, index]))
return [...providers()].sort((first, second) => (
(ranks.get(first.id) ?? ranks.size) - (ranks.get(second.id) ?? ranks.size)
))
})
const draggingProvider = computed(() => orderedProviders.value.find(provider => provider.id === draggingProviderId.value))
const dragPreviewStyle = computed(() => ({
left: `${Math.max(8, Math.min(pointerPosition.value.clientX + 12, window.innerWidth - 208))}px`,
top: `${Math.max(8, Math.min(pointerPosition.value.clientY + 12, window.innerHeight - 48))}px`,
}))
function moveProvider(providerId: string, targetId: string) {
const visibleIds = orderedProviders.value.map(provider => provider.id)
const sourceIndex = visibleIds.indexOf(providerId)
const targetIndex = visibleIds.indexOf(targetId)
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return
visibleIds.splice(sourceIndex, 1)
visibleIds.splice(targetIndex, 0, providerId)
const visibleSet = new Set(visibleIds)
const allIds = [...new Set([...normalizedOrder.value, ...knownOrder.value, ...visibleIds])]
let visibleIndex = 0
savedOrder.value = allIds.map(currentId => visibleSet.has(currentId) ? visibleIds[visibleIndex++] ?? currentId : currentId)
announcement.value = `${legacyT('展示顺序已更新')}: ${orderedProviders.value[targetIndex]?.name} (${targetIndex + 1}/${visibleIds.length})`
}
function updateDropTarget() {
const target = document.elementFromPoint(pointerPosition.value.clientX, pointerPosition.value.clientY)
?.closest<HTMLElement>('[data-provider-sort-id]')
const targetId = target?.dataset.providerSortId
dropTargetId.value = target && container.value?.contains(target)
&& targetId !== draggingProviderId.value
&& orderedProviders.value.some(provider => provider.id === targetId)
? targetId ?? null
: null
}
function findScrollContainer(handle: HTMLElement): HTMLElement | null {
let ancestor = handle.parentElement
while (ancestor && ancestor !== document.body) {
if (/(auto|scroll)/.test(getComputedStyle(ancestor).overflowY) && ancestor.scrollHeight > ancestor.clientHeight) {
return ancestor
}
ancestor = ancestor.parentElement
}
return document.scrollingElement as HTMLElement | null
}
const { pause, resume } = useRafFn(() => {
const scrollContainer = pointerDrag?.scrollContainer
if (scrollContainer) {
const bounds = scrollContainer.getBoundingClientRect()
const isDocument = scrollContainer === document.scrollingElement
const top = isDocument ? 0 : Math.max(0, bounds.top)
const bottom = isDocument ? window.innerHeight : Math.min(window.innerHeight, bounds.bottom)
const pointerY = pointerPosition.value.clientY
const pointerX = pointerPosition.value.clientX
if (isDocument || (pointerX >= bounds.left && pointerX <= bounds.right)) {
if (pointerY < top + 48) {
scrollContainer.scrollTop -= Math.min(12, (top + 48 - pointerY) / 4)
} else if (pointerY > bottom - 48) {
scrollContainer.scrollTop += Math.min(12, (pointerY - bottom + 48) / 4)
}
}
}
updateDropTarget()
}, { immediate: false })
function cancelDrag() {
const previous = pointerDrag
pointerDrag = null
pause()
if (draggingProviderId.value) suppressClickUntil = Date.now() + 250
draggingProviderId.value = null
dropTargetId.value = null
if (previous?.handle.hasPointerCapture?.(previous.pointerId)) {
previous.handle.releasePointerCapture(previous.pointerId)
}
}
function startDrag(providerId: string, event: PointerEvent) {
if (event.button !== 0 || event.isPrimary === false || orderedProviders.value.length < 2) return
if (!orderedProviders.value.some(provider => provider.id === providerId)) return
const handle = event.currentTarget
if (!(handle instanceof HTMLElement)) return
cancelDrag()
event.preventDefault()
handle.focus({ preventScroll: true })
pointerDrag = {
providerId,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
handle,
scrollContainer: findScrollContainer(handle),
}
pointerPosition.value = { clientX: event.clientX, clientY: event.clientY }
handle.setPointerCapture?.(event.pointerId)
}
function handlePointerMove(event: PointerEvent) {
if (!pointerDrag || pointerDrag.pointerId !== event.pointerId) return
pointerPosition.value = { clientX: event.clientX, clientY: event.clientY }
if (!draggingProviderId.value) {
if (Math.hypot(event.clientX - pointerDrag.startX, event.clientY - pointerDrag.startY) < 5) return
draggingProviderId.value = pointerDrag.providerId
resume()
}
event.preventDefault()
updateDropTarget()
}
function handlePointerUp(event: PointerEvent) {
if (!pointerDrag || pointerDrag.pointerId !== event.pointerId) return
const providerId = draggingProviderId.value
if (providerId) {
pointerPosition.value = { clientX: event.clientX, clientY: event.clientY }
updateDropTarget()
}
const targetId = dropTargetId.value
cancelDrag()
if (providerId && targetId) moveProvider(providerId, targetId)
}
function handleSortKeydown(providerId: string, event: KeyboardEvent) {
if (event.key === 'Escape') {
cancelDrag()
return
}
const directions: Record<string, number> = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }
const direction = directions[event.key]
if (direction === undefined || pointerDrag) return
event.preventDefault()
const index = orderedProviders.value.findIndex(provider => provider.id === providerId)
const target = orderedProviders.value[index + direction]
if (index < 0 || !target) return
const handle = event.currentTarget as HTMLElement
moveProvider(providerId, target.id)
void nextTick(() => handle.focus({ preventScroll: true }))
}
function handleSortClick(event: MouseEvent) {
if (!draggingProviderId.value && Date.now() >= suppressClickUntil) return
suppressClickUntil = 0
event.preventDefault()
event.stopPropagation()
}
function sortItemClass(providerId: string) {
return {
'opacity-40': draggingProviderId.value === providerId,
'ring-2 ring-inset ring-primary/60 bg-primary/5': dropTargetId.value === providerId,
}
}
watch(() => providers().map(provider => provider.id), (providerIds) => {
knownOrder.value = [...new Set([...knownOrder.value, ...providerIds])]
cancelDrag()
}, { immediate: true })
useEventListener(window, 'pointermove', handlePointerMove, { passive: false })
useEventListener(window, 'pointerup', handlePointerUp)
useEventListener(window, 'pointercancel', (event) => {
if (pointerDrag?.pointerId === event.pointerId) cancelDrag()
})
useEventListener(window, 'lostpointercapture', (event) => {
if (pointerDrag?.pointerId === event.pointerId) cancelDrag()
})
useEventListener(window, 'blur', cancelDrag)
useEventListener(window, 'keydown', (event) => {
if (event.key === 'Escape') cancelDrag()
})
onScopeDispose(cancelDrag)
return {
orderedProviders,
draggingProvider,
dragPreviewStyle,
announcement,
startDrag,
cancelDrag,
handleSortKeydown,
handleSortClick,
sortItemClass,
}
}