diff --git a/IntelPresentMon/AppCef/ipm-ui-vue/src/stores/loadout.ts b/IntelPresentMon/AppCef/ipm-ui-vue/src/stores/loadout.ts index e914c267..d246fa17 100644 --- a/IntelPresentMon/AppCef/ipm-ui-vue/src/stores/loadout.ts +++ b/IntelPresentMon/AppCef/ipm-ui-vue/src/stores/loadout.ts @@ -1,13 +1,13 @@ -import { ref, reactive, readonly, computed } from 'vue' +import { ref, reactive, readonly, computed } from 'vue' import { defineStore } from 'pinia' import { Api } from '@/core/api' import { getEnumValues } from '@/core/meta' -import { asGraph, asReadout, WidgetType, type Widget } from '@/core/widget' +import { asGraph, asReadout, WidgetType, type Widget, regenerateKeys as regenerateWidgetKeys, resetKeySequence as resetWidgetKeySequence } from '@/core/widget' import { signature, type LoadoutFile } from '@/core/loadout' import type { QualifiedMetric } from '@/core/qualified-metric' import { makeDefaultGraph, type Graph } from '@/core/graph' import { makeDefaultReadout, type Readout } from '@/core/readout' -import { makeDefaultWidgetMetric, type WidgetMetric } from '@/core/widget-metric' +import { makeDefaultWidgetMetric, type WidgetMetric, resetKeySequence as resetWidgetMetricKeySequence } from '@/core/widget-metric' import { debounce, type DelayedTask } from '@/core/timing' import { migrateLoadout } from '@/core/loadout-migration' import { useIntrospectionStore } from './introspection' @@ -34,6 +34,14 @@ export const useLoadoutStore = defineStore('loadout', () => { }) // === Functions === + function normalizeWidgetKeys(items: Widget[]) { + resetWidgetKeySequence() + resetWidgetMetricKeySequence() + for (const widget of items) { + regenerateWidgetKeys(widget) + } + } + // loads loadout from json string data without any error handling async function parseAndReplace(payload: string) { const loadout = JSON.parse(payload) as LoadoutFile @@ -45,6 +53,7 @@ export const useLoadoutStore = defineStore('loadout', () => { console.info(`loadout migrated to ${signature.version}`) } loadout.widgets = loadout.widgets.filter(w => w.metrics.length > 0) + normalizeWidgetKeys(loadout.widgets) widgets.value.splice(0, widgets.value.length, ...loadout.widgets) } @@ -138,8 +147,25 @@ export const useLoadoutStore = defineStore('loadout', () => { } async function moveWidget(from: number, to: number) { - const movedItem = widgets.value.splice(from, 1)[0] - widgets.value.splice(to, 0, movedItem) + if (!Number.isInteger(from) || !Number.isInteger(to)) { + return + } + if (from < 0 || from >= widgets.value.length) { + return + } + if (to < 0 || to > widgets.value.length) { + return + } + if (from === to) { + return + } + const movedItem = widgets.value[from] + if (movedItem === undefined) { + return + } + widgets.value.splice(from, 1) + const insertIndex = Math.min(to, widgets.value.length) + widgets.value.splice(insertIndex, 0, movedItem) } // wraps parseAndReplace in try/catch and handles errors @@ -181,4 +207,4 @@ export const useLoadoutStore = defineStore('loadout', () => { browseAndSerialize, serializeCurrent } -}) \ No newline at end of file +}) diff --git a/IntelPresentMon/AppCef/ipm-ui-vue/src/views/LoadoutConfigView.vue b/IntelPresentMon/AppCef/ipm-ui-vue/src/views/LoadoutConfigView.vue index bc43e7dc..855a49b7 100644 --- a/IntelPresentMon/AppCef/ipm-ui-vue/src/views/LoadoutConfigView.vue +++ b/IntelPresentMon/AppCef/ipm-ui-vue/src/views/LoadoutConfigView.vue @@ -3,7 +3,7 @@ import Sortable from 'sortablejs'; import { useIntrospectionStore } from '@/stores/introspection'; import type { Widget } from '@/core/widget'; import LoadoutRow from '@/components/LoadoutRow.vue'; -import { onMounted, ref } from 'vue'; +import { onMounted, onUnmounted, ref } from 'vue'; import { useLoadoutStore } from '@/stores/loadout'; import { useNotificationsStore } from '@/stores/notifications'; import { Api } from '@/core/api'; @@ -15,30 +15,86 @@ const loadout = useLoadoutStore() const notes = useNotificationsStore() const activeAdapterId = ref(null); +// Sortable instance used to manage drag-and-drop reordering for widget rows. +// This is created on mount and destroyed on unmount to avoid stale DOM references. let sort: Sortable|null = null; +// Convert Sortable event indices into validated array indices. +// Sortable can report undefined (no drop), negative indices (drag outside), +// or out-of-range indices when the drop target is not inside the container. +// allowEnd controls whether we accept an index equal to length (append). +const getSortableIndex = (value: number | null | undefined, length: number, allowEnd: boolean): number | null => { + if (typeof value !== 'number' || !Number.isInteger(value)) { + return null; + } + if (value < 0) { + return null; + } + if (allowEnd) { + return value <= length ? value : null; + } + return value < length ? value : null; +}; + +// Restore DOM order to match the store order when a drag ends in an invalid state. +// This avoids the UI showing a different order than the canonical loadout data. +const resetSortOrder = () => { + if (!sort) { + return; + } + const order = loadout.widgets.map(w => String(w.key)); + if (order.length === 0) { + return; + } + sort.sort(order); +}; + onMounted(() => { // hook up the Sortable.js drag and drop machinery to our elements sort = new Sortable(document.querySelector('#sortable-row-container')!, { + // Allow only the widget rows (not the add button or other elements) to be draggable. draggable: '.sortable-row', + // Require dragging from the grip icon to avoid accidental drags while editing fields. handle: '.sortable-handle', + // Use the fallback drag implementation so we control visuals consistently across browsers. forceFallback: true, + // Identify each row by data-id so Sortable can reorder by stable widget keys. + dataIdAttr: 'data-id', + // Maintain a grabbing cursor on the dragged element for consistent feedback. onChoose: e => e.target.classList.add('sortable-grabbing'), onUnchoose: e => e.target.classList.remove('sortable-grabbing'), onStart: e => e.target.classList.add('sortable-grabbing'), onEnd: e => { + // Always clear the grabbing cursor and attempt to apply the reorder. e.target.classList.remove('sortable-grabbing') dragReorder(e) }, }) }) +// Apply the reorder to the store if indices are valid, otherwise revert DOM order. const dragReorder = (e: Sortable.SortableEvent) => { - if (e.oldIndex !== undefined && e.newIndex !== undefined) { - loadout.moveWidget(e.oldIndex, e.newIndex) + const length = loadout.widgets.length; + // oldIndex must be a valid index into the current list (no "append" allowed). + const from = getSortableIndex(e.oldIndex, length, false); + // newIndex allows append, but still must be within bounds. + const to = getSortableIndex(e.newIndex, length, true); + if (from === null || to === null || from === to) { + resetSortOrder(); + return; } + // Delegate to the store to ensure any other logic stays centralized. + loadout.moveWidget(from, to); } +onUnmounted(() => { + // Destroy Sortable on teardown to avoid handlers firing on a stale DOM. + if (sort) { + sort.destroy(); + sort = null; + } +}); + async function save() { try { await loadout.browseAndSerialize() @@ -82,15 +138,15 @@ const removeWidget = (widgetIdx:number) => { +
Add New Widget
- Save @@ -188,6 +244,7 @@ const removeWidget = (widgetIdx:number) => { width: 100%; display: flex; justify-content: center; + margin-top: 12px; } .sortable-grabbing * { cursor: grabbing !important; @@ -207,4 +264,4 @@ const removeWidget = (widgetIdx:number) => { .link-head:hover { color: rgb(var(--v-theme-primary)); } - \ No newline at end of file +