From 8069633815ccf6b1b6f3aca59715d1ec655cfd32 Mon Sep 17 00:00:00 2001 From: tdgao Date: Tue, 18 Aug 2026 20:24:05 -0600 Subject: [PATCH 01/19] fix: fixed height on modal --- .../ui/new-icon-editor-notification/apply-new-icons-modal.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app-frontend/src/components/ui/new-icon-editor-notification/apply-new-icons-modal.vue b/apps/app-frontend/src/components/ui/new-icon-editor-notification/apply-new-icons-modal.vue index 1fe58f333d..91c4d02a60 100644 --- a/apps/app-frontend/src/components/ui/new-icon-editor-notification/apply-new-icons-modal.vue +++ b/apps/app-frontend/src/components/ui/new-icon-editor-notification/apply-new-icons-modal.vue @@ -154,7 +154,7 @@ defineExpose({ show, hide }) :disable-close="applying" class="!overflow-hidden !rounded-[20px]" > -
+
Date: Tue, 18 Aug 2026 20:45:18 -0600 Subject: [PATCH 02/19] fix: conditional auto padding being applied on user avatars --- apps/app-frontend/src/components/ui/AccountsCard.vue | 7 ++++++- apps/frontend/src/components/ui/NotificationItem.vue | 2 +- apps/frontend/src/error.vue | 2 +- packages/ui/src/components/base/Avatar.vue | 4 +++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue index ac9136f6c2..bcb144f450 100644 --- a/apps/app-frontend/src/components/ui/AccountsCard.vue +++ b/apps/app-frontend/src/components/ui/AccountsCard.vue @@ -20,6 +20,7 @@
- +

- + diff --git a/apps/frontend/src/error.vue b/apps/frontend/src/error.vue index aec5aeb970..ce915498fe 100644 --- a/apps/frontend/src/error.vue +++ b/apps/frontend/src/error.vue @@ -34,7 +34,7 @@

- + {{ auth.user.username }}
{ > - -
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({ 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, }" >
) { 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) { 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) { 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) { 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) { 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) { 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) { 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) { 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) { 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) { } 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 From 51bdd0a2e4a32a277b6065103663ce352fbe0760 Mon Sep 17 00:00:00 2001 From: tdgao Date: Tue, 18 Aug 2026 21:43:10 -0600 Subject: [PATCH 04/19] feat: add drag handle to resize num items in jump in --- .../components/ui/world/RecentWorldsList.vue | 353 +++++++++++++----- apps/app-frontend/src/pages/Index.vue | 2 +- 2 files changed, 268 insertions(+), 87 deletions(-) diff --git a/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue index bf6c0d84d8..a75de082e2 100644 --- a/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue +++ b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue @@ -1,11 +1,17 @@ + + diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue index 9e7c51fd1c..93fc59a663 100644 --- a/apps/app-frontend/src/pages/Index.vue +++ b/apps/app-frontend/src/pages/Index.vue @@ -102,7 +102,7 @@ function handlePageOption({ option }: { option: string }) {
Date: Tue, 18 Aug 2026 22:08:26 -0600 Subject: [PATCH 05/19] qa --- .../icon-editor-modal/index.vue | 45 ++++++++++--------- .../adapters/sqlite/instance_rows.rs | 4 +- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue index 12c53f983d..730e3a0164 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue @@ -1,6 +1,7 @@ - - diff --git a/apps/app-frontend/src/components/ui/context-menu/index.vue b/apps/app-frontend/src/components/ui/context-menu/index.vue new file mode 100644 index 0000000000..93732168bb --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/index.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/context-menu/types.ts b/apps/app-frontend/src/components/ui/context-menu/types.ts new file mode 100644 index 0000000000..fe45d2b6ff --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/types.ts @@ -0,0 +1,41 @@ +import type { ComponentPublicInstance } from 'vue' + +export type ContextMenuDivider = { + type: string +} + +export type ContextMenuAction = { + name: string + color?: string + children?: ContextMenuOption[] +} + +export type ContextMenuParentAction = ContextMenuAction & { + children: ContextMenuOption[] +} + +export type ContextMenuOption = ContextMenuDivider | ContextMenuAction + +export type ContextMenuSelection = { + item: unknown + option: string +} + +export type ContextMenuEmit = { + (event: 'menu-closed'): void + (event: 'option-clicked', selection: ContextMenuSelection): void +} + +export type Point = { + x: number + y: number +} + +export type ViewportRect = { + width: number + height: number + offsetTop: number + offsetLeft: number +} + +export type ButtonRefElement = Element | ComponentPublicInstance | null diff --git a/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts b/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts new file mode 100644 index 0000000000..7ffa4fef4f --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts @@ -0,0 +1,170 @@ +import type { CSSProperties, Ref } from 'vue' +import { computed, nextTick, ref } from 'vue' + +import type { ContextMenuParentAction, Point, ViewportRect } from './types' + +const MENU_GAP = 8 +const VIEWPORT_MARGIN = 10 + +type ContextMenuPositionOptions = { + shown: Ref + activeOption: Ref + activeOptionIndex: Ref + isMobileSubmenuLayout: Ref + contextMenu: Ref + submenu: Ref + optionButtonRefs: Map +} + +export function useContextMenuPosition({ + shown, + activeOption, + activeOptionIndex, + isMobileSubmenuLayout, + contextMenu, + submenu, + optionButtonRefs, +}: ContextMenuPositionOptions) { + const menuStyle = ref({ left: '0px', top: '0px' }) + const menuAnchor = ref({ x: 0, y: 0 }) + const submenuPosition = ref({ x: 0, y: 0 }) + const hasSubmenuPosition = ref(false) + let positionRafId: number | null = null + + const submenuStyle = computed(() => { + if (isMobileSubmenuLayout.value) return menuStyle.value + + return { + left: `${submenuPosition.value.x}px`, + top: `${submenuPosition.value.y}px`, + } + }) + + function updateMenuPosition(x: number, y: number) { + if (!contextMenu.value) return + + const viewport = getViewportRect() + const menuRect = contextMenu.value.getBoundingClientRect() + const viewportLeft = viewport.offsetLeft + const viewportTop = viewport.offsetTop + const viewportRight = viewport.offsetLeft + viewport.width + const viewportBottom = viewport.offsetTop + viewport.height + const anchorX = x + viewport.offsetLeft + const anchorY = y + viewport.offsetTop + const left = Math.min( + Math.max(viewportLeft + VIEWPORT_MARGIN, anchorX + MENU_GAP), + Math.max(viewportLeft + VIEWPORT_MARGIN, viewportRight - menuRect.width - VIEWPORT_MARGIN), + ) + const top = Math.min( + Math.max(viewportTop + VIEWPORT_MARGIN, anchorY + MENU_GAP), + Math.max(viewportTop + VIEWPORT_MARGIN, viewportBottom - menuRect.height - VIEWPORT_MARGIN), + ) + + menuStyle.value = { left: `${left}px`, top: `${top}px` } + if (activeOption.value) scheduleSubmenuPositionUpdate() + } + + function updateSubmenuPosition() { + if (!activeOption.value || activeOptionIndex.value === null) return false + + if (isMobileSubmenuLayout.value) { + hasSubmenuPosition.value = true + return true + } + + const optionButton = optionButtonRefs.get(activeOptionIndex.value) + if (!optionButton || !contextMenu.value) return false + + const viewport = getViewportRect() + const buttonRect = optionButton.getBoundingClientRect() + const menuRect = contextMenu.value.getBoundingClientRect() + const submenuRect = submenu.value?.getBoundingClientRect() + const submenuWidth = submenuRect?.width ?? menuRect.width + const submenuHeight = submenuRect?.height ?? 100 + const direction = getSubmenuOpenDirection(menuRect, submenuWidth, viewport) + const preferredLeft = + direction === 'right' + ? buttonRect.right + MENU_GAP + : buttonRect.left - submenuWidth - MENU_GAP + const minLeft = viewport.offsetLeft + VIEWPORT_MARGIN + const maxLeft = Math.max( + minLeft, + viewport.offsetLeft + viewport.width - submenuWidth - VIEWPORT_MARGIN, + ) + const minTop = viewport.offsetTop + VIEWPORT_MARGIN + const maxTop = Math.max( + minTop, + viewport.offsetTop + viewport.height - submenuHeight - VIEWPORT_MARGIN, + ) + + submenuPosition.value = { + x: Math.min(Math.max(minLeft, preferredLeft), maxLeft), + y: Math.min(Math.max(minTop, buttonRect.top), maxTop), + } + hasSubmenuPosition.value = true + return true + } + + function scheduleSubmenuPositionUpdate(retries = 8) { + nextTick(() => { + if (!shown.value || !activeOption.value) return + + const hasRenderedSubmenu = submenu.value !== null + if (updateSubmenuPosition()) { + if (!hasRenderedSubmenu) nextTick(updateSubmenuPosition) + return + } + + if (retries > 0) setTimeout(() => scheduleSubmenuPositionUpdate(retries - 1), 0) + }) + } + + function schedulePositionUpdate() { + if (!shown.value || positionRafId !== null) return + + positionRafId = window.requestAnimationFrame(() => { + positionRafId = null + updateMenuPosition(menuAnchor.value.x, menuAnchor.value.y) + }) + } + + function cancelPositionUpdate() { + if (positionRafId !== null) window.cancelAnimationFrame(positionRafId) + } + + return { + menuStyle, + menuAnchor, + submenuStyle, + hasSubmenuPosition, + updateMenuPosition, + scheduleSubmenuPositionUpdate, + schedulePositionUpdate, + cancelPositionUpdate, + } +} + +function getViewportRect(): ViewportRect { + const visualViewport = window.visualViewport + return { + width: visualViewport?.width ?? window.innerWidth, + height: visualViewport?.height ?? window.innerHeight, + offsetTop: visualViewport?.offsetTop ?? 0, + offsetLeft: visualViewport?.offsetLeft ?? 0, + } +} + +function getSubmenuOpenDirection( + menuRect: DOMRect, + submenuWidth: number, + viewport: ViewportRect, +): 'left' | 'right' { + const viewportLeft = viewport.offsetLeft + const viewportRight = viewport.offsetLeft + viewport.width + const rightSpace = viewportRight - menuRect.right - MENU_GAP - VIEWPORT_MARGIN + const leftSpace = menuRect.left - viewportLeft - MENU_GAP - VIEWPORT_MARGIN + + if (rightSpace >= submenuWidth) return 'right' + if (leftSpace >= submenuWidth) return 'left' + return rightSpace >= leftSpace ? 'right' : 'left' +} diff --git a/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts b/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts new file mode 100644 index 0000000000..a74b5012e0 --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts @@ -0,0 +1,357 @@ +import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' + +import type { + ButtonRefElement, + ContextMenuAction, + ContextMenuEmit, + ContextMenuOption, + ContextMenuParentAction, + Point, +} from './types' +import { useContextMenuPosition } from './use-context-menu-position' +import { + focusRelativeButton, + getFocusableButtons, + hasChildren, + isAction, + isInstanceLink, + isPointInTriangle, +} from './utils' + +const MOBILE_SUBMENU_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)' +const CONTEXT_MENU_OPEN_EVENT = 'modrinth-context-menu-open' + +export function useContextMenu(emit: ContextMenuEmit) { + const item = ref(null) + const contextMenu = ref(null) + const submenu = ref(null) + const options = ref([]) + const shown = ref(false) + const activeOptionIndex = ref(null) + const pendingOptionIndex = ref(null) + const isCursorInsideSubmenu = ref(false) + const isMobileSubmenuLayout = ref(false) + const lastMousePosition = ref(null) + const contextMenuId = Symbol() + const optionButtonRefs = new Map() + const submenuButtonRefs = new Map() + let previousMousePosition: Point | null = null + let pendingOptionTimeout: ReturnType | null = null + let mobileSubmenuMediaQuery: MediaQueryList | null = null + + const activeOption = computed(() => { + if (activeOptionIndex.value === null) return null + const option = options.value[activeOptionIndex.value] + return option && isAction(option) && hasChildren(option) ? option : null + }) + + const { + menuStyle, + menuAnchor, + submenuStyle, + hasSubmenuPosition, + updateMenuPosition, + scheduleSubmenuPositionUpdate, + schedulePositionUpdate, + cancelPositionUpdate, + } = useContextMenuPosition({ + shown, + activeOption, + activeOptionIndex, + isMobileSubmenuLayout, + contextMenu, + submenu, + optionButtonRefs, + }) + + const isMobileActiveSubmenu = computed( + () => isMobileSubmenuLayout.value && activeOption.value !== null && hasSubmenuPosition.value, + ) + + function isOptionVisible(option: ContextMenuOption): option is ContextMenuAction { + return isAction(option) && !(isInstanceLink(item.value) && option.name === 'add_content') + } + + function setOptionButtonRef(index: number, element: ButtonRefElement) { + setButtonRef(optionButtonRefs, index, element) + } + + function setSubmenuButtonRef(index: number, element: ButtonRefElement) { + setButtonRef(submenuButtonRefs, index, element) + } + + function hideMenu() { + if (!shown.value) return + + shown.value = false + deactivateSubmenu() + emit('menu-closed') + } + + function showMenu(event: MouseEvent, passedItem: unknown, passedOptions: ContextMenuOption[]) { + window.dispatchEvent(new CustomEvent(CONTEXT_MENU_OPEN_EVENT, { detail: contextMenuId })) + + item.value = passedItem + options.value = passedOptions + menuAnchor.value = { x: event.clientX, y: event.clientY } + shown.value = true + deactivateSubmenu() + syncMobileSubmenuLayout() + nextTick(() => updateMenuPosition(event.clientX, event.clientY)) + } + + function handleOptionClick(option: ContextMenuAction, index: number) { + if (hasChildren(option)) { + activateSubmenu(index) + return + } + + optionClicked(option.name) + } + + function optionClicked(option: string) { + emit('option-clicked', { item: item.value, option }) + hideMenu() + } + + function handleOptionFocus(option: ContextMenuAction, index: number) { + if (hasChildren(option) && !isMobileSubmenuLayout.value) { + activateSubmenu(index) + } else if (!hasChildren(option)) { + deactivateSubmenu() + } + } + + function handleOptionMouseEnter(option: ContextMenuAction, index: number) { + if (isMobileSubmenuLayout.value) return + + if (activeOptionIndex.value === null) { + if (hasChildren(option)) activateSubmenu(index) + return + } + + if (activeOptionIndex.value === index) return + + if (!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)) { + commitHoveredOption(index) + return + } + + pendingOptionIndex.value = index + clearPendingOptionTimeout() + pendingOptionTimeout = setTimeout(() => { + if (pendingOptionIndex.value !== index) return + if (isCursorInsideSubmenu.value) { + pendingOptionIndex.value = null + return + } + + commitHoveredOption(index) + }, 180) + } + + function commitHoveredOption(index: number) { + const option = options.value[index] + if (option && hasChildren(option)) { + activateSubmenu(index) + } else { + deactivateSubmenu() + } + } + + function activateSubmenu(index: number) { + clearPendingOptionTimeout() + pendingOptionIndex.value = null + activeOptionIndex.value = index + hasSubmenuPosition.value = false + scheduleSubmenuPositionUpdate() + } + + function deactivateSubmenu() { + clearPendingOptionTimeout() + activeOptionIndex.value = null + pendingOptionIndex.value = null + hasSubmenuPosition.value = false + isCursorInsideSubmenu.value = false + lastMousePosition.value = null + previousMousePosition = null + } + + function returnToMenu() { + const previousIndex = activeOptionIndex.value + deactivateSubmenu() + nextTick(() => { + if (previousIndex !== null) optionButtonRefs.get(previousIndex)?.focus() + }) + } + + function handleSubmenuMouseEnter() { + isCursorInsideSubmenu.value = true + clearPendingOptionTimeout() + pendingOptionIndex.value = null + } + + function handleMenuMouseMove(event: MouseEvent, source: 'menu' | 'submenu') { + previousMousePosition = lastMousePosition.value + lastMousePosition.value = { x: event.clientX, y: event.clientY } + + if ( + source === 'menu' && + pendingOptionIndex.value !== null && + !isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition) + ) { + commitHoveredOption(pendingOptionIndex.value) + } + } + + function isCursorAimingAtSubmenu(cursor: Point | null, origin: Point | null) { + const submenuRect = submenu.value?.getBoundingClientRect() + if (!submenuRect || !cursor || !origin) return false + + const submenuTargetX = + origin.x <= submenuRect.left + ? submenuRect.left + : origin.x >= submenuRect.right + ? submenuRect.right + : cursor.x <= submenuRect.left + ? submenuRect.left + : submenuRect.right + const upperTarget = { x: submenuTargetX, y: submenuRect.top - 20 } + const lowerTarget = { x: submenuTargetX, y: submenuRect.bottom + 20 } + + return isPointInTriangle(cursor, origin, upperTarget, lowerTarget) + } + + function handleMenuKeydown(event: KeyboardEvent) { + const buttons = getFocusableButtons(optionButtonRefs) + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1) + } else if (event.key === 'Home' || event.key === 'End') { + event.preventDefault() + buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus() + } else if (event.key === 'ArrowRight') { + const focusedIndex = [...optionButtonRefs.entries()].find( + ([, button]) => button === document.activeElement, + )?.[0] + const focusedOption = focusedIndex === undefined ? undefined : options.value[focusedIndex] + if (focusedIndex !== undefined && focusedOption && hasChildren(focusedOption)) { + event.preventDefault() + activateSubmenu(focusedIndex) + nextTick(() => getFocusableButtons(submenuButtonRefs)[0]?.focus()) + } + } else if (event.key === 'Escape') { + event.preventDefault() + hideMenu() + } + } + + function handleSubmenuKeydown(event: KeyboardEvent) { + const buttons = getFocusableButtons(submenuButtonRefs) + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1) + } else if (event.key === 'Home' || event.key === 'End') { + event.preventDefault() + buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus() + } else if (event.key === 'ArrowLeft') { + event.preventDefault() + returnToMenu() + } else if (event.key === 'Escape') { + event.preventDefault() + hideMenu() + } + } + + function syncMobileSubmenuLayout(event?: MediaQueryListEvent) { + isMobileSubmenuLayout.value = event?.matches ?? mobileSubmenuMediaQuery?.matches ?? false + } + + function handleDocumentKeydown(event: KeyboardEvent) { + if (shown.value && event.key === 'Escape') hideMenu() + } + + function handleContextMenuOpen(event: Event) { + if (shown.value && event instanceof CustomEvent && event.detail !== contextMenuId) hideMenu() + } + + function handleClickOutside(event: MouseEvent) { + const target = event.target + if (!(target instanceof Node)) return + if (!contextMenu.value?.contains(target) && !submenu.value?.contains(target)) hideMenu() + } + + onMounted(() => { + mobileSubmenuMediaQuery = window.matchMedia(MOBILE_SUBMENU_LAYOUT_QUERY) + syncMobileSubmenuLayout() + mobileSubmenuMediaQuery.addEventListener('change', syncMobileSubmenuLayout) + window.addEventListener('click', handleClickOutside) + window.addEventListener('resize', schedulePositionUpdate) + window.addEventListener('scroll', schedulePositionUpdate, true) + window.visualViewport?.addEventListener('scroll', schedulePositionUpdate) + window.visualViewport?.addEventListener('resize', schedulePositionUpdate) + window.addEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen) + document.addEventListener('keydown', handleDocumentKeydown) + }) + + onBeforeUnmount(() => { + clearPendingOptionTimeout() + cancelPositionUpdate() + mobileSubmenuMediaQuery?.removeEventListener('change', syncMobileSubmenuLayout) + window.removeEventListener('click', handleClickOutside) + window.removeEventListener('resize', schedulePositionUpdate) + window.removeEventListener('scroll', schedulePositionUpdate, true) + window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate) + window.visualViewport?.removeEventListener('resize', schedulePositionUpdate) + window.removeEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen) + document.removeEventListener('keydown', handleDocumentKeydown) + }) + + function clearPendingOptionTimeout() { + if (!pendingOptionTimeout) return + clearTimeout(pendingOptionTimeout) + pendingOptionTimeout = null + } + + return { + shown, + contextMenu, + submenu, + options, + menuStyle, + submenuStyle, + activeOption, + activeOptionIndex, + pendingOptionIndex, + hasSubmenuPosition, + isMobileSubmenuLayout, + isMobileActiveSubmenu, + isCursorInsideSubmenu, + isOptionVisible, + setOptionButtonRef, + setSubmenuButtonRef, + showMenu, + hideMenu, + handleOptionClick, + optionClicked, + handleOptionFocus, + handleOptionMouseEnter, + returnToMenu, + handleSubmenuMouseEnter, + handleMenuMouseMove, + handleMenuKeydown, + handleSubmenuKeydown, + } +} + +function setButtonRef( + buttonRefs: Map, + index: number, + element: ButtonRefElement, +) { + if (element instanceof HTMLElement) { + buttonRefs.set(index, element) + } else { + buttonRefs.delete(index) + } +} diff --git a/apps/app-frontend/src/components/ui/context-menu/utils.ts b/apps/app-frontend/src/components/ui/context-menu/utils.ts new file mode 100644 index 0000000000..e7bd7c79d2 --- /dev/null +++ b/apps/app-frontend/src/components/ui/context-menu/utils.ts @@ -0,0 +1,59 @@ +import type { + ContextMenuAction, + ContextMenuDivider, + ContextMenuOption, + ContextMenuParentAction, + Point, +} from './types' + +export function isAction(option: ContextMenuOption): option is ContextMenuAction { + return 'name' in option +} + +export function isDivider(option: ContextMenuOption): option is ContextMenuDivider { + return 'type' in option && option.type === 'divider' +} + +export function hasChildren(option: ContextMenuOption): option is ContextMenuParentAction { + return isAction(option) && Boolean(option.children?.length) +} + +export function isInstanceLink(value: unknown) { + if (!value || typeof value !== 'object') return false + + if ('instance' in value) { + const instance = value.instance + return Boolean(instance && typeof instance === 'object' && 'link' in instance && instance.link) + } + + return 'link' in value && Boolean(value.link) +} + +export function getFocusableButtons(buttonRefs: Map) { + return [...buttonRefs.entries()] + .sort(([left], [right]) => left - right) + .map(([, button]) => button) + .filter((button) => button.offsetParent !== null) +} + +export function focusRelativeButton(buttons: HTMLElement[], direction: 1 | -1) { + if (!buttons.length) return + + const currentIndex = buttons.indexOf(document.activeElement as HTMLElement) + const nextIndex = + currentIndex === -1 ? (direction === 1 ? 0 : buttons.length - 1) : currentIndex + direction + buttons[(nextIndex + buttons.length) % buttons.length]?.focus() +} + +export function isPointInTriangle(point: Point, a: Point, b: Point, c: Point) { + const area = triangleArea(a, b, c) + const area1 = triangleArea(point, b, c) + const area2 = triangleArea(a, point, c) + const area3 = triangleArea(a, b, point) + + return Math.abs(area - (area1 + area2 + area3)) < 0.5 +} + +function triangleArea(a: Point, b: Point, c: Point) { + return Math.abs((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2) +} diff --git a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue index e53e7cc0f6..e9678ec7b7 100644 --- a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue +++ b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue @@ -11,7 +11,7 @@ import { import { useTemplateRef } from 'vue' import { useRouter } from 'vue-router' -import ContextMenu from '@/components/ui/ContextMenu.vue' +import ContextMenu from '@/components/ui/context-menu/index.vue' import type { FriendWithUserData } from '@/helpers/friends.ts' const { formatMessage } = useVIntl() diff --git a/apps/app-frontend/src/components/ui/library/index.vue b/apps/app-frontend/src/components/ui/library/index.vue index fe9d64f433..b3e6ad4cb3 100644 --- a/apps/app-frontend/src/components/ui/library/index.vue +++ b/apps/app-frontend/src/components/ui/library/index.vue @@ -14,7 +14,7 @@ import { defineMessages, useVIntl } from '@modrinth/ui' import { computed, nextTick, onDeactivated, onUnmounted, ref, toRef, watch } from 'vue' import Draggable from 'vuedraggable' -import ContextMenu from '@/components/ui/ContextMenu.vue' +import ContextMenu from '@/components/ui/context-menu/index.vue' import GroupInstancesModal from '@/components/ui/library/group-instances-modal.vue' import InstanceGroup from '@/components/ui/library/instance-group/index.vue' import InstanceGroupDnd from '@/components/ui/library/instance-group/instance-group-dnd.vue' diff --git a/apps/app-frontend/src/components/ui/library/instance-group/index.vue b/apps/app-frontend/src/components/ui/library/instance-group/index.vue index 11bc5397bc..4a179654b8 100644 --- a/apps/app-frontend/src/components/ui/library/instance-group/index.vue +++ b/apps/app-frontend/src/components/ui/library/instance-group/index.vue @@ -21,7 +21,7 @@ import { } from '@modrinth/ui' import { computed, inject, nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue' -import ContextMenu from '@/components/ui/ContextMenu.vue' +import ContextMenu from '@/components/ui/context-menu/index.vue' import GroupActionButtons from '@/components/ui/library/instance-group/group-action-buttons.vue' import InstanceCard from '@/components/ui/library/instance-group/instance-card.vue' import type { diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index e366aa6558..863a4436fd 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -332,6 +332,9 @@ "app.home.jump-back-in.new-instance": { "message": "New instance" }, + "app.home.jump-back-in.resize": { + "message": "Drag to resize" + }, "app.home.jump-back-in.title": { "message": "Jump in" }, diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue index 0a7a18263d..d4f5a895dd 100644 --- a/apps/app-frontend/src/pages/Browse.vue +++ b/apps/app-frontend/src/pages/Browse.vue @@ -38,7 +38,7 @@ import { computed, ref, shallowRef, watch } from 'vue' import type { LocationQuery } from 'vue-router' import { useRoute, useRouter } from 'vue-router' -import ContextMenu from '@/components/ui/ContextMenu.vue' +import ContextMenu from '@/components/ui/context-menu/index.vue' import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse' import { useAppEvent } from '@/composables/use-app-event' import { get_project, get_search_results_v3, get_version_many } from '@/helpers/cache.js' diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue index 93fc59a663..89b0661d9d 100644 --- a/apps/app-frontend/src/pages/Index.vue +++ b/apps/app-frontend/src/pages/Index.vue @@ -4,7 +4,7 @@ import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/u import dayjs from 'dayjs' import { computed, inject, onActivated, ref } from 'vue' -import ContextMenu from '@/components/ui/ContextMenu.vue' +import ContextMenu from '@/components/ui/context-menu/index.vue' import LibrarySection from '@/components/ui/library/index.vue' import WelcomeScreen from '@/components/ui/WelcomeScreen.vue' import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue' diff --git a/apps/app-frontend/src/pages/instance/layout.vue b/apps/app-frontend/src/pages/instance/layout.vue index 6a9f27d4f1..664bcc322a 100644 --- a/apps/app-frontend/src/pages/instance/layout.vue +++ b/apps/app-frontend/src/pages/instance/layout.vue @@ -122,7 +122,7 @@ import relativeTime from 'dayjs/plugin/relativeTime' import { computed, type ComputedRef, onUnmounted, ref, shallowRef, watch } from 'vue' import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router' -import ContextMenu from '@/components/ui/ContextMenu.vue' +import ContextMenu from '@/components/ui/context-menu/index.vue' import ExportModal from '@/components/ui/ExportModal.vue' import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue' import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue' diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index 798a9e97e6..d5e75cb711 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.vue @@ -287,7 +287,7 @@ import { computed, ref, shallowRef, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { SwapIcon } from '@/assets/icons/index.js' -import ContextMenu from '@/components/ui/ContextMenu.vue' +import ContextMenu from '@/components/ui/context-menu/index.vue' import InstanceIndicator from '@/components/ui/InstanceIndicator.vue' import { fetchCachedServerStatus, diff --git a/packages/ui/src/stories/app/ContextMenu.stories.ts b/packages/ui/src/stories/app/ContextMenu.stories.ts new file mode 100644 index 0000000000..a521f89caf --- /dev/null +++ b/packages/ui/src/stories/app/ContextMenu.stories.ts @@ -0,0 +1,97 @@ +import { CopyIcon, FolderOpenIcon, PlayIcon, SettingsIcon, TrashIcon } from '@modrinth/assets' +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { fn } from 'storybook/test' +import { nextTick, onMounted, ref } from 'vue' + +import ContextMenu from '../../../../../apps/app-frontend/src/components/ui/context-menu/index.vue' +import type { ContextMenuOption } from '../../../../../apps/app-frontend/src/components/ui/context-menu/types' + +const options: ContextMenuOption[] = [ + { name: 'play', color: 'primary' }, + { + name: 'copy', + children: [{ name: 'copy_name' }, { name: 'copy_path' }, { name: 'copy_id' }], + }, + { name: 'open_folder' }, + { type: 'divider' }, + { name: 'settings' }, + { name: 'delete', color: 'danger' }, +] + +const meta = { + title: 'App/Context Menu', + component: ContextMenu, + parameters: { + layout: 'fullscreen', + }, + args: { + onMenuClosed: fn(), + onOptionClicked: fn(), + }, + render: (args) => ({ + components: { + ContextMenu, + CopyIcon, + FolderOpenIcon, + PlayIcon, + SettingsIcon, + TrashIcon, + }, + setup() { + const contextMenu = ref>() + const target = ref() + const item = { id: 'storybook-instance', name: 'Storybook Instance' } + + function openMenu(event: MouseEvent) { + contextMenu.value?.showMenu(event, item, options) + } + + onMounted(() => { + nextTick(() => { + const rect = target.value?.getBoundingClientRect() + openMenu( + new MouseEvent('contextmenu', { + clientX: (rect?.left ?? 80) + 80, + clientY: (rect?.top ?? 80) + 80, + }), + ) + }) + }) + + return { args, contextMenu, openMenu, target } + }, + template: /*html*/ ` +
+
+

+ Right-click anywhere in this panel to reopen the menu. +

+
+ + + + + + + + + + + +
+ `, + }), +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} From 9cf119d65b1fe2caab2b8cd68659da0cd22b75a9 Mon Sep 17 00:00:00 2001 From: tdgao Date: Tue, 18 Aug 2026 22:38:20 -0600 Subject: [PATCH 07/19] feat: hook up icon editing in sub menu --- .../src/components/ui/library/index.vue | 49 ++++++++++++ .../src/components/ui/library/use-library.ts | 80 ++++++++++++++++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/apps/app-frontend/src/components/ui/library/index.vue b/apps/app-frontend/src/components/ui/library/index.vue index b3e6ad4cb3..1b13a40cd5 100644 --- a/apps/app-frontend/src/components/ui/library/index.vue +++ b/apps/app-frontend/src/components/ui/library/index.vue @@ -1,20 +1,24 @@