mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
feat: allow ungrouped to be dragged
This commit is contained in:
@@ -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
|
||||
|
||||
+21
-10
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user