feat: allow ungrouped to be dragged

This commit is contained in:
tdgao
2026-08-18 20:57:13 -06:00
parent 17eb3430d0
commit 17b5736a48
5 changed files with 129 additions and 75 deletions
@@ -12,7 +12,10 @@
<span class="text-2xl font-semibold text-contrast">
{{
formatMessage(messages.title, {
groupName: groupInstancesModalGroup?.name ?? '',
groupName:
groupInstancesModalGroup?.id === 'group:none'
? formatMessage(messages.ungrouped)
: (groupInstancesModalGroup?.name ?? ''),
})
}}
</span>
@@ -53,6 +56,10 @@
</div>
<Button
:type="selectedGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
:disabled="
groupInstancesModalGroup?.id === 'group:none' &&
selectedGroupInstanceIds.has(instance.id)
"
@click="toggleGroupInstance(instance.id)"
>
<CheckIcon v-if="selectedGroupInstanceIds.has(instance.id)" />
@@ -100,6 +107,10 @@ import { getInstanceIconUrl } from '@/helpers/instance'
const { formatMessage } = useVIntl()
const messages = defineMessages({
ungrouped: {
id: 'app.library.group.ungrouped',
defaultMessage: 'Ungrouped',
},
title: {
id: 'app.library.group.instances-modal.title',
defaultMessage: 'Add instances to "{groupName}"',
@@ -100,40 +100,35 @@ const visibleInstanceGroups = computed(() =>
),
)
const visibleCustomGroups = computed(() =>
const visibleReorderableGroups = computed(() =>
displayState.value.group === 'Group'
? visibleInstanceGroups.value.filter(
(group) => group.id !== FAVORITES_GROUP_ID && group.id !== 'group:none',
)
? visibleInstanceGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID)
: [],
)
const visibleFavoritesGroup = computed(() =>
visibleInstanceGroups.value.find((group) => group.id === FAVORITES_GROUP_ID),
)
const visibleUngroupedGroup = computed(() =>
visibleInstanceGroups.value.find((group) => group.id === 'group:none'),
)
const draggableCustomGroups = ref<InstanceGroupType[]>([])
const draggableGroups = ref<InstanceGroupType[]>([])
const libraryGroupsContainer = ref<HTMLElement>()
const isDraggingGroup = ref(false)
const GROUP_REORDERING_CLASS = 'instance-group-reordering'
const canDragReorderGroups = computed(
() => !reorderingGroups.value && draggableCustomGroups.value.length > 1,
() => !reorderingGroups.value && draggableGroups.value.length > 1,
)
watch(
visibleCustomGroups,
visibleReorderableGroups,
(groups) => {
if (!isDraggingGroup.value) {
const previousGroupTops = getCustomGroupTops()
draggableCustomGroups.value = [...groups]
void nextTick(() => animateCustomGroupReorder(previousGroupTops))
const previousGroupTops = getReorderableGroupTops()
draggableGroups.value = [...groups]
void nextTick(() => animateGroupReorder(previousGroupTops))
}
},
{ immediate: true },
)
function getCustomGroupTops() {
function getReorderableGroupTops() {
const groupTops = new Map<string, number>()
const groupElements = libraryGroupsContainer.value?.querySelectorAll<HTMLElement>(
'[data-instance-group-reorder-id]',
@@ -149,7 +144,7 @@ function getCustomGroupTops() {
return groupTops
}
function animateCustomGroupReorder(previousGroupTops: Map<string, number>) {
function animateGroupReorder(previousGroupTops: Map<string, number>) {
if (
previousGroupTops.size === 0 ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -185,10 +180,10 @@ function onGroupDragEnd() {
isDraggingGroup.value = false
document.documentElement.classList.remove(GROUP_REORDERING_CLASS)
const currentGroupIds = visibleCustomGroups.value.map((group) => group.id)
const orderedGroupIds = draggableCustomGroups.value.map((group) => group.id)
const currentGroupIds = visibleReorderableGroups.value.map((group) => group.id)
const orderedGroupIds = draggableGroups.value.map((group) => group.id)
if (orderedGroupIds.every((groupId, index) => groupId === currentGroupIds[index])) {
draggableCustomGroups.value = [...visibleCustomGroups.value]
draggableGroups.value = [...visibleReorderableGroups.value]
return
}
@@ -313,7 +308,7 @@ watch(selectedLibraryInstances, (selectedInstances) => {
</div>
<Draggable
:list="draggableCustomGroups"
:list="draggableGroups"
class="flex flex-col"
item-key="id"
:disabled="!canDragReorderGroups"
@@ -341,6 +336,7 @@ watch(selectedLibraryInstances, (selectedInstances) => {
>
<InstanceGroup
:can-drag-reorder="canDragReorderGroups"
:hide-header="visibleInstanceGroups.length === 1"
:instance-group="instanceGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === instanceGroup.id ? anchorInstance?.instanceId : null
@@ -353,20 +349,6 @@ watch(selectedLibraryInstances, (selectedInstances) => {
</div>
</template>
</Draggable>
<div v-if="visibleUngroupedGroup" class="min-w-0">
<InstanceGroup
:hide-header="visibleInstanceGroups.length === 1"
:instance-group="visibleUngroupedGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === 'group:none' ? anchorInstance.instanceId : null
"
@toggle-selection="
(instanceId: string, shiftKey: boolean) =>
handleToggleInstance('group:none', instanceId, shiftKey)
"
/>
</div>
</div>
<TransitionGroup
@@ -2,16 +2,25 @@
import { ArrowDownIcon, ArrowUpIcon, EditIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
import { defineMessages, IconButton, useVIntl } from '@modrinth/ui'
defineProps<{
deleting?: boolean
canMoveDown: boolean
canMoveUp: boolean
onAddToGroup: () => void
onDeleteGroup: () => void
onEditGroupName: () => void
onMoveDown: () => void
onMoveUp: () => void
}>()
withDefaults(
defineProps<{
deleting?: boolean
canMoveDown: boolean
canMoveUp: boolean
onAddToGroup: () => void
onDeleteGroup: () => void
onEditGroupName: () => void
onMoveDown: () => void
onMoveUp: () => void
showDelete?: boolean
showEdit?: boolean
}>(),
{
deleting: false,
showDelete: true,
showEdit: true,
},
)
const { formatMessage } = useVIntl()
@@ -64,6 +73,7 @@ const messages = defineMessages({
<ArrowDownIcon />
</IconButton>
<IconButton
v-if="showEdit"
v-tooltip="formatMessage(messages.editGroupName)"
:label="formatMessage(messages.editGroupName)"
type="quiet"
@@ -82,6 +92,7 @@ const messages = defineMessages({
<SquarePlusIcon />
</IconButton>
<IconButton
v-if="showDelete"
v-tooltip="formatMessage(messages.deleteGroup)"
:label="formatMessage(messages.deleteGroup)"
type="quiet"
@@ -84,6 +84,9 @@ const isFavorites = computed(() => props.instanceGroup.id === FAVORITES_GROUP_ID
const isCustomGroup = computed(
() => displayState.value.group === 'Group' && !isUngrouped.value && !isFavorites.value,
)
const isReorderableGroup = computed(
() => displayState.value.group === 'Group' && !isFavorites.value,
)
const groupContextMenuOpen = ref(false)
const isGroupToggleBlocked = computed(
() => isSearching.value || groupContextMenuOpen.value || Boolean(groupNameInput.value?.isEditing),
@@ -383,7 +386,7 @@ onMounted(startInstanceGridResizeObserver)
v-if="!hideHeader"
class="group/header h-10 flex w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
:class="{
'instance-group-reorder-handle': isCustomGroup && canDragReorder,
'instance-group-reorder-handle': isReorderableGroup && canDragReorder,
}"
>
<div
@@ -442,7 +445,7 @@ onMounted(startInstanceGridResizeObserver)
</div>
<div class="min-w-0 flex-1" />
<GroupActionButtons
v-if="isCustomGroup"
v-if="isCustomGroup || isUngrouped"
:can-move-down="canMoveGroupDown(instanceGroup.id)"
:can-move-up="canMoveGroupUp(instanceGroup.id)"
:deleting="deletingGroup"
@@ -451,6 +454,8 @@ onMounted(startInstanceGridResizeObserver)
:on-edit-group-name="() => groupNameInput?.startEditing()"
:on-move-down="() => moveGroup(instanceGroup.id, 1)"
:on-move-up="() => moveGroup(instanceGroup.id, -1)"
:show-delete="!isUngrouped"
:show-edit="!isUngrouped"
/>
</div>
<Accordion
@@ -158,12 +158,14 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
group: LibraryGroupBy
sortBy: LibrarySort
collapsedGroups: string[]
ungroupedGroupPosition: number
}>(
'Instances-grid-display-state',
{
group: 'Group',
sortBy: 'Last played',
collapsedGroups: [],
ungroupedGroupPosition: Number.MAX_SAFE_INTEGER,
},
localStorage,
{ mergeDefaults: true },
@@ -209,10 +211,15 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
return a.name.localeCompare(b.name)
})
})
const groupInstancesModalGroup = computed(
() =>
libraryGroups.value.find((group) => group.id === groupInstancesModalGroupId.value) ?? null,
)
const groupInstancesModalGroup = computed(() => {
if (groupInstancesModalGroupId.value === 'group:none') {
return { id: 'group:none', name: 'None' }
}
return (
libraryGroups.value.find((group) => group.id === groupInstancesModalGroupId.value) ?? null
)
})
const groupInstances = computed(() => {
const query = groupInstancesSearch.value.trim().toLowerCase()
@@ -227,8 +234,23 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const customLibraryGroups = computed(() =>
libraryGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID),
)
const customGroupOrder = computed(
() => new Map(customLibraryGroups.value.map((group, index) => [group.id, index])),
const orderedLibraryGroupIds = computed(() => {
const groupIds = customLibraryGroups.value.map((group) => group.id)
const storedUngroupedGroupPosition = displayState.value.ungroupedGroupPosition
const ungroupedGroupPosition = Math.min(
Math.max(
Number.isFinite(storedUngroupedGroupPosition)
? Math.trunc(storedUngroupedGroupPosition)
: Number.MAX_SAFE_INTEGER,
0,
),
groupIds.length,
)
groupIds.splice(ungroupedGroupPosition, 0, 'group:none')
return groupIds
})
const libraryGroupOrder = computed(
() => new Map(orderedLibraryGroupIds.value.map((groupId, index) => [groupId, index])),
)
const refreshGroups = async () => {
@@ -436,11 +458,9 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
if (a.id === b.id) return 0
if (a.id === FAVORITES_GROUP_ID) return -1
if (b.id === FAVORITES_GROUP_ID) return 1
if (a.id === 'group:none') return 1
if (b.id === 'group:none') return -1
const aOrder = customGroupOrder.value.get(a.id) ?? Number.MAX_SAFE_INTEGER
const bOrder = customGroupOrder.value.get(b.id) ?? Number.MAX_SAFE_INTEGER
const aOrder = libraryGroupOrder.value.get(a.id) ?? Number.MAX_SAFE_INTEGER
const bOrder = libraryGroupOrder.value.get(b.id) ?? Number.MAX_SAFE_INTEGER
return aOrder - bOrder || a.key.localeCompare(b.key) || a.id.localeCompare(b.id)
})
}
@@ -701,13 +721,17 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const openGroupInstancesModal = (groupId: string) => {
const group = libraryGroups.value.find((candidate) => candidate.id === groupId)
if (!group) return
if (!group && groupId !== 'group:none') return
groupInstancesModalGroupId.value = groupId
groupInstancesSearch.value = ''
selectedGroupInstanceIds.value = new Set(
instances.value
.filter((instance) => instance.group_ids.includes(groupId))
.filter((instance) =>
groupId === 'group:none'
? instance.group_ids.length === 0
: instance.group_ids.includes(groupId),
)
.map((instance) => instance.id),
)
isGroupInstancesModalOpen.value = true
@@ -722,6 +746,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const selectedIds = new Set(selectedGroupInstanceIds.value)
if (selectedIds.has(instanceId)) {
if (groupInstancesModalGroupId.value === 'group:none') return
selectedIds.delete(instanceId)
} else {
selectedIds.add(instanceId)
@@ -734,15 +759,20 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const groupId = groupInstancesModalGroupId.value
if (!groupId || savingGroupInstances.value) return false
const changedInstances = instances.value.filter(
(instance) =>
instance.group_ids.includes(groupId) !== selectedGroupInstanceIds.value.has(instance.id),
)
const isUngrouped = groupId === 'group:none'
const changedInstances = instances.value.filter((instance) => {
const isSelected = selectedGroupInstanceIds.value.has(instance.id)
return isUngrouped
? isSelected && instance.group_ids.length > 0
: instance.group_ids.includes(groupId) !== isSelected
})
const operations = changedInstances.map((instance) => {
const shouldIncludeGroup = selectedGroupInstanceIds.value.has(instance.id)
const nextGroupIds = shouldIncludeGroup
? [...instance.group_ids, groupId]
: instance.group_ids.filter((instanceGroupId) => instanceGroupId !== groupId)
const nextGroupIds = isUngrouped
? []
: shouldIncludeGroup
? [...instance.group_ids, groupId]
: instance.group_ids.filter((instanceGroupId) => instanceGroupId !== groupId)
return {
instance,
@@ -964,14 +994,16 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const canMoveGroupUp = (groupId: string) =>
!reorderingGroups.value &&
customLibraryGroups.value.findIndex((group) => group.id === groupId) > 0
orderedLibraryGroupIds.value.findIndex((orderedGroupId) => orderedGroupId === groupId) > 0
const canMoveGroupDown = (groupId: string) => {
const groupIndex = customLibraryGroups.value.findIndex((group) => group.id === groupId)
const groupIndex = orderedLibraryGroupIds.value.findIndex(
(orderedGroupId) => orderedGroupId === groupId,
)
return (
!reorderingGroups.value &&
groupIndex >= 0 &&
groupIndex < customLibraryGroups.value.length - 1
groupIndex < orderedLibraryGroupIds.value.length - 1
)
}
@@ -979,35 +1011,48 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
if (reorderingGroups.value) return false
const previousGroups = libraryGroups.value
const previousUngroupedGroupPosition = displayState.value.ungroupedGroupPosition
const customGroupsById = new Map(customLibraryGroups.value.map((group) => [group.id, group]))
const reorderableGroupIds = new Set([...customGroupsById.keys(), 'group:none'])
const orderedGroupIdSet = new Set(orderedGroupIds)
if (
orderedGroupIdSet.size !== orderedGroupIds.length ||
orderedGroupIds.some((groupId) => !customGroupsById.has(groupId))
orderedGroupIds.some((groupId) => !reorderableGroupIds.has(groupId))
) {
return false
}
const orderedGroups = orderedGroupIds.map((groupId) => customGroupsById.get(groupId)!)
let orderedGroupIndex = 0
const reorderedCustomGroups = customLibraryGroups.value.map((group) =>
orderedGroupIdSet.has(group.id) ? orderedGroups[orderedGroupIndex++] : group,
const reorderedGroupIds = orderedLibraryGroupIds.value.map((groupId) =>
orderedGroupIdSet.has(groupId) ? orderedGroupIds[orderedGroupIndex++] : groupId,
)
if (reorderedCustomGroups.every((group, index) => group === customLibraryGroups.value[index])) {
if (
reorderedGroupIds.every((groupId, index) => groupId === orderedLibraryGroupIds.value[index])
) {
return false
}
const reorderedCustomGroups = reorderedGroupIds
.filter((groupId) => groupId !== 'group:none')
.map((groupId) => customGroupsById.get(groupId)!)
const customGroupOrderChanged = reorderedCustomGroups.some(
(group, index) => group !== customLibraryGroups.value[index],
)
const favoriteGroups = previousGroups.filter((group) => group.id === FAVORITES_GROUP_ID)
libraryGroups.value = [...favoriteGroups, ...reorderedCustomGroups]
displayState.value.ungroupedGroupPosition = reorderedGroupIds.indexOf('group:none')
reorderingGroups.value = true
try {
await setInstanceGroupOrder(reorderedCustomGroups.map((group) => group.id))
if (customGroupOrderChanged) {
await setInstanceGroupOrder(reorderedCustomGroups.map((group) => group.id))
}
return true
} catch (error) {
libraryGroups.value = previousGroups
displayState.value.ungroupedGroupPosition = previousUngroupedGroupPosition
handleError(toError(error))
await refreshGroups()
return false
@@ -1017,7 +1062,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
}
const moveGroup = async (groupId: string, direction: -1 | 1) => {
const orderedGroupIds = customLibraryGroups.value.map((group) => group.id)
const orderedGroupIds = [...orderedLibraryGroupIds.value]
const groupIndex = orderedGroupIds.indexOf(groupId)
const targetIndex = groupIndex + direction