From d1dbc0ed664582398b7c46b43eac863b04545b8c Mon Sep 17 00:00:00 2001 From: andrewwallacespeckle Date: Fri, 19 Sep 2025 14:14:42 +0100 Subject: [PATCH 1/5] feat(fe): preserveSelectionHighlightFilter --- .../viewer/filters/filter/Header.vue | 10 --- .../components/viewer/models/Card.vue | 3 +- .../lib/viewer/composables/setup/filters.ts | 76 ++++++++++++++----- .../viewer/composables/setup/highlighting.ts | 35 ++++++--- .../lib/viewer/composables/setup/postSetup.ts | 21 ++++- 5 files changed, 100 insertions(+), 45 deletions(-) diff --git a/packages/frontend-2/components/viewer/filters/filter/Header.vue b/packages/frontend-2/components/viewer/filters/filter/Header.vue index 5e2bca8a5..429206e46 100644 --- a/packages/frontend-2/components/viewer/filters/filter/Header.vue +++ b/packages/frontend-2/components/viewer/filters/filter/Header.vue @@ -95,10 +95,6 @@ import { useFilterUtilities } from '~/lib/viewer/composables/filtering/filtering import { useFilterColoringHelpers } from '~/lib/viewer/composables/filtering/coloringHelpers' import type { FilterData } from '~/lib/viewer/helpers/filters/types' import { FilterType } from '~/lib/viewer/helpers/filters/types' -import { - useHighlightedObjectsUtilities, - useSelectionUtilities -} from '~/lib/viewer/composables/ui' const props = defineProps<{ filter: FilterData @@ -112,8 +108,6 @@ const { removeActiveFilter, toggleFilterApplied, getPropertyName, filters } = useFilterUtilities() const { toggleColorFilter } = useFilterColoringHelpers() -const { clearHighlightedObjects } = useHighlightedObjectsUtilities() -const { clearSelection } = useSelectionUtilities() const emit = defineEmits<{ swapProperty: [filterId: string] @@ -124,8 +118,6 @@ const isColoringActive = computed(() => { }) const removeFilter = () => { - clearHighlightedObjects() - clearSelection() removeActiveFilter(props.filter.id) } @@ -134,8 +126,6 @@ const toggleVisibility = () => { } const toggleColors = () => { - clearHighlightedObjects() - clearSelection() toggleColorFilter(props.filter.id) } diff --git a/packages/frontend-2/components/viewer/models/Card.vue b/packages/frontend-2/components/viewer/models/Card.vue index a901f7917..186739e63 100644 --- a/packages/frontend-2/components/viewer/models/Card.vue +++ b/packages/frontend-2/components/viewer/models/Card.vue @@ -140,7 +140,7 @@ const { hideObjects, showObjects, isolateObjects, unIsolateObjects } = const { zoom } = useCameraUtilities() const { items } = useInjectedViewerRequestedResources() const { resourceItems } = useInjectedViewerLoadedResources() -const { addToSelectionFromObjectIds, clearSelection } = useSelectionUtilities() +const { addToSelectionFromObjectIds } = useSelectionUtilities() const { viewer: { @@ -313,7 +313,6 @@ const handleClick = () => { if (!props.isExpanded) { emit('toggle-expansion') } else { - clearSelection() addToSelectionFromObjectIds(modelObjectIds.value) } } diff --git a/packages/frontend-2/lib/viewer/composables/setup/filters.ts b/packages/frontend-2/lib/viewer/composables/setup/filters.ts index 66bfdab7a..710be570d 100644 --- a/packages/frontend-2/lib/viewer/composables/setup/filters.ts +++ b/packages/frontend-2/lib/viewer/composables/setup/filters.ts @@ -1,11 +1,12 @@ import type { FilterData } from '~/lib/viewer/helpers/filters/types' import type { SpeckleObject } from '@speckle/viewer' import type { Raw } from 'vue' -import { FilteringExtension } from '@speckle/viewer' +import { FilteringExtension, SelectionExtension } from '@speckle/viewer' import { watchTriggerable } from '@vueuse/core' import { useInjectedViewerState } from '~/lib/viewer/composables/setup' import { useOnViewerLoadComplete } from '~/lib/viewer/composables/viewer' import { useFilteringDataStore } from '~/lib/viewer/composables/filtering/dataStore' +import { getGlobalHighlightExtension } from '~/lib/viewer/composables/setup/highlighting' /** * Setup composable for filter-related state @@ -61,6 +62,39 @@ export const useManualFilteringPostSetup = () => { const filteringExtension = () => instance.getExtension(FilteringExtension) + /** + * Preserve selection and highlighting state during filtering operations + * This replicates LegacyViewer's preserveSelectionHighlightFilter function + */ + const preserveSelectionHighlightFilter = (filterFn: () => T): T => { + const selectionExtension = instance.getExtension(SelectionExtension) + const highlightExtension = getGlobalHighlightExtension() + + // 1. SAVE current state from viewer extensions + const selectedObjects = selectionExtension + .getSelectedObjects() + .map((obj) => obj.id as string) + const highlightedObjects = + highlightExtension?.getSelectedObjects().map((obj) => obj.id as string) || [] + + // 2. CLEAR viewer extensions directly + if (selectedObjects.length) selectionExtension.clearSelection() + if (highlightedObjects.length && highlightExtension) { + highlightExtension.clearSelection() + } + + // 3. EXECUTE the filtering operation + const result = filterFn() + + // 4. RESTORE to viewer extensions directly + if (selectedObjects.length) selectionExtension.selectObjects(selectedObjects) + if (highlightedObjects.length && highlightExtension) { + highlightExtension.selectObjects(highlightedObjects) + } + + return result + } + /** * Watch for changes to manually isolated object IDs */ @@ -69,17 +103,19 @@ export const useManualFilteringPostSetup = () => { (newIds, oldIds) => { if (!newIds || !oldIds) return - const extension = filteringExtension() + preserveSelectionHighlightFilter(() => { + const extension = filteringExtension() - const toIsolate = newIds.filter((id) => !oldIds.includes(id)) - if (toIsolate.length > 0) { - extension.isolateObjects(toIsolate, 'manual-isolation', true, true) - } + const toIsolate = newIds.filter((id) => !oldIds.includes(id)) + if (toIsolate.length > 0) { + extension.isolateObjects(toIsolate, 'manual-isolation', true, true) + } - const toUnIsolate = oldIds.filter((id) => !newIds.includes(id)) - if (toUnIsolate.length > 0) { - extension.unIsolateObjects(toUnIsolate, 'manual-isolation', true, true) - } + const toUnIsolate = oldIds.filter((id) => !newIds.includes(id)) + if (toUnIsolate.length > 0) { + extension.unIsolateObjects(toUnIsolate, 'manual-isolation', true, true) + } + }) }, { deep: true } ) @@ -92,17 +128,19 @@ export const useManualFilteringPostSetup = () => { (newIds, oldIds) => { if (!newIds || !oldIds) return - const extension = filteringExtension() + preserveSelectionHighlightFilter(() => { + const extension = filteringExtension() - const toHide = newIds.filter((id) => !oldIds.includes(id)) - if (toHide.length > 0) { - extension.hideObjects(toHide, 'manual-hiding', false, false) - } + const toHide = newIds.filter((id) => !oldIds.includes(id)) + if (toHide.length > 0) { + extension.hideObjects(toHide, 'manual-hiding', false, false) + } - const toShow = oldIds.filter((id) => !newIds.includes(id)) - if (toShow.length > 0) { - extension.showObjects(toShow, 'manual-hiding', false) - } + const toShow = oldIds.filter((id) => !newIds.includes(id)) + if (toShow.length > 0) { + extension.showObjects(toShow, 'manual-hiding', false) + } + }) }, { deep: true } ) diff --git a/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts b/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts index 4edb828f1..c52ffb264 100644 --- a/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts +++ b/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts @@ -10,6 +10,7 @@ import { import { useInjectedViewerState } from '~/lib/viewer/composables/setup' import { useOnViewerLoadComplete } from '~/lib/viewer/composables/viewer' import { ViewerRenderPageType } from '~/lib/viewer/helpers/state' +import { useScopedState } from '~~/lib/common/composables/scopedState' /** * Highlighting extension that replicates LegacyViewer's HighlightExtension @@ -38,6 +39,29 @@ class HighlightExtension extends SelectionExtension { } } +/** + * Scoped state for the global highlight extension instance + */ +const useHighlightExtensionState = () => + useScopedState('highlightExtension', () => + shallowRef(null) + ) + +/** + * Get the global highlight extension + */ +export const getGlobalHighlightExtension = ( + instance?: IViewer +): HighlightExtension | null => { + const highlightExtensionState = useHighlightExtensionState() + + if (!highlightExtensionState.value && instance) { + highlightExtensionState.value = instance.createExtension(HighlightExtension) + } + + return highlightExtensionState.value +} + /** * Post-setup integration that sets up highlighting extension and watches state * This should only be called once during post-setup after the viewer is initialized. @@ -51,15 +75,8 @@ export const useHighlightingPostSetup = () => { if (pageType.value === ViewerRenderPageType.Presentation) return - const highlightExtension = ref(null) - // Get the highlighting extension instance - const getHighlightExtension = () => { - if (!highlightExtension.value) { - highlightExtension.value = instance.createExtension(HighlightExtension) - } - return highlightExtension.value - } + const getHighlightExtension = () => getGlobalHighlightExtension(instance) useOnViewerLoadComplete( ({ isInitial }) => { @@ -83,8 +100,6 @@ export const useHighlightingPostSetup = () => { } if (oldIds && isEqual(newIds, oldIds)) return - - // Clear and re-select to avoid accumulation extension.clearSelection() if (newIds.length > 0) { extension.selectObjects(newIds) diff --git a/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts b/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts index 7754c742b..f7c6441cd 100644 --- a/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts +++ b/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts @@ -67,7 +67,10 @@ import { } from '~/lib/viewer/composables/setup/filters' import { useFilterUtilities } from '~/lib/viewer/composables/filtering/filtering' import { useFilteringSetup } from '~/lib/viewer/composables/filtering/setup' -import { useHighlightingPostSetup } from '~/lib/viewer/composables/setup/highlighting' +import { + useHighlightingPostSetup, + getGlobalHighlightExtension +} from '~/lib/viewer/composables/setup/highlighting' function useViewerLoadCompleteEventHandler() { const state = useInjectedViewerState() @@ -566,12 +569,22 @@ function useViewerFiltersIntegration() { ).filter(isNonNullable) if (arraysEqual(newIds, oldIds)) return - state.ui.highlightedObjectIds.value = [] - const selectionExtension = instance.getExtension(SelectionExtension) + const currentViewerSelection = selectionExtension + .getSelectedObjects() + .map((obj) => obj.id as string) + + if (arraysEqual(currentViewerSelection.sort(), newIds.sort())) { + return + } + + state.ui.highlightedObjectIds.value = [] + const highlightExtension = getGlobalHighlightExtension() + if (highlightExtension) { + highlightExtension.clearSelection() + } selectionExtension.clearSelection() - if (newVal.length > 0) { selectionExtension.selectObjects(newIds) } From 8c6225887e3416636ea833cfbdacd21f3cd14191 Mon Sep 17 00:00:00 2001 From: andrewwallacespeckle Date: Fri, 19 Sep 2025 14:31:53 +0100 Subject: [PATCH 2/5] Add docblock. Cleanup onBeforeUnmount --- .../lib/viewer/composables/setup/highlighting.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts b/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts index c52ffb264..2916c7469 100644 --- a/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts +++ b/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts @@ -48,7 +48,11 @@ const useHighlightExtensionState = () => ) /** - * Get the global highlight extension + * Get the global highlight extension instance. + * + * Unlike built-in extensions (SelectionExtension), our custom HighlightExtension + * must be stored globally to prevent multiple instances from being created, + * which would cause material storage conflicts and highlighting issues. */ export const getGlobalHighlightExtension = ( instance?: IViewer @@ -107,4 +111,12 @@ export const useHighlightingPostSetup = () => { }, { immediate: true, flush: 'sync' } ) + + // Clean up the global highlight extension on unmount + onBeforeUnmount(() => { + const highlightExtensionState = useHighlightExtensionState() + if (highlightExtensionState.value) { + highlightExtensionState.value = null + } + }) } From bd30c65de4a26b7840ad787efe721ac8f8b913db Mon Sep 17 00:00:00 2001 From: andrewwallacespeckle Date: Fri, 19 Sep 2025 14:38:49 +0100 Subject: [PATCH 3/5] Use difference --- .../frontend-2/lib/viewer/composables/setup/postSetup.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts b/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts index f7c6441cd..28a0662ab 100644 --- a/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts +++ b/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts @@ -574,7 +574,10 @@ function useViewerFiltersIntegration() { .getSelectedObjects() .map((obj) => obj.id as string) - if (arraysEqual(currentViewerSelection.sort(), newIds.sort())) { + if ( + currentViewerSelection.length === newIds.length && + difference(currentViewerSelection, newIds).length === 0 + ) { return } From 15ef1af0a5aebc8568d82039036e597314b85af2 Mon Sep 17 00:00:00 2001 From: andrewwallacespeckle Date: Fri, 19 Sep 2025 14:46:19 +0100 Subject: [PATCH 4/5] Create the highlighting extension once during setup --- .../lib/viewer/composables/setup/filters.ts | 4 +- .../viewer/composables/setup/highlighting.ts | 45 +++++-------------- .../lib/viewer/composables/setup/postSetup.ts | 4 +- 3 files changed, 14 insertions(+), 39 deletions(-) diff --git a/packages/frontend-2/lib/viewer/composables/setup/filters.ts b/packages/frontend-2/lib/viewer/composables/setup/filters.ts index 710be570d..719001185 100644 --- a/packages/frontend-2/lib/viewer/composables/setup/filters.ts +++ b/packages/frontend-2/lib/viewer/composables/setup/filters.ts @@ -6,7 +6,7 @@ import { watchTriggerable } from '@vueuse/core' import { useInjectedViewerState } from '~/lib/viewer/composables/setup' import { useOnViewerLoadComplete } from '~/lib/viewer/composables/viewer' import { useFilteringDataStore } from '~/lib/viewer/composables/filtering/dataStore' -import { getGlobalHighlightExtension } from '~/lib/viewer/composables/setup/highlighting' +import { getHighlightExtension } from '~/lib/viewer/composables/setup/highlighting' /** * Setup composable for filter-related state @@ -68,7 +68,7 @@ export const useManualFilteringPostSetup = () => { */ const preserveSelectionHighlightFilter = (filterFn: () => T): T => { const selectionExtension = instance.getExtension(SelectionExtension) - const highlightExtension = getGlobalHighlightExtension() + const highlightExtension = getHighlightExtension(instance) // 1. SAVE current state from viewer extensions const selectedObjects = selectionExtension diff --git a/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts b/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts index 2916c7469..df6bedbb5 100644 --- a/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts +++ b/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts @@ -10,7 +10,6 @@ import { import { useInjectedViewerState } from '~/lib/viewer/composables/setup' import { useOnViewerLoadComplete } from '~/lib/viewer/composables/viewer' import { ViewerRenderPageType } from '~/lib/viewer/helpers/state' -import { useScopedState } from '~~/lib/common/composables/scopedState' /** * Highlighting extension that replicates LegacyViewer's HighlightExtension @@ -40,30 +39,11 @@ class HighlightExtension extends SelectionExtension { } /** - * Scoped state for the global highlight extension instance + * Get the highlight extension instance from the viewer. + * The extension is created once during setup and then retrieved everywhere else. */ -const useHighlightExtensionState = () => - useScopedState('highlightExtension', () => - shallowRef(null) - ) - -/** - * Get the global highlight extension instance. - * - * Unlike built-in extensions (SelectionExtension), our custom HighlightExtension - * must be stored globally to prevent multiple instances from being created, - * which would cause material storage conflicts and highlighting issues. - */ -export const getGlobalHighlightExtension = ( - instance?: IViewer -): HighlightExtension | null => { - const highlightExtensionState = useHighlightExtensionState() - - if (!highlightExtensionState.value && instance) { - highlightExtensionState.value = instance.createExtension(HighlightExtension) - } - - return highlightExtensionState.value +export const getHighlightExtension = (instance: IViewer): HighlightExtension | null => { + return instance.getExtension(HighlightExtension) } /** @@ -79,13 +59,16 @@ export const useHighlightingPostSetup = () => { if (pageType.value === ViewerRenderPageType.Presentation) return + // Create the highlighting extension once during setup + instance.createExtension(HighlightExtension) + // Get the highlighting extension instance - const getHighlightExtension = () => getGlobalHighlightExtension(instance) + const getHighlightExtensionInstance = () => getHighlightExtension(instance) useOnViewerLoadComplete( ({ isInitial }) => { if (!isInitial) return - getHighlightExtension() + getHighlightExtensionInstance() }, { initialOnly: true } ) @@ -94,7 +77,7 @@ export const useHighlightingPostSetup = () => { watch( highlightedObjectIds, (newIds, oldIds) => { - const extension = getHighlightExtension() + const extension = getHighlightExtensionInstance() if (!extension) return // Clear all current highlights if new list is empty @@ -111,12 +94,4 @@ export const useHighlightingPostSetup = () => { }, { immediate: true, flush: 'sync' } ) - - // Clean up the global highlight extension on unmount - onBeforeUnmount(() => { - const highlightExtensionState = useHighlightExtensionState() - if (highlightExtensionState.value) { - highlightExtensionState.value = null - } - }) } diff --git a/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts b/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts index 28a0662ab..269e95237 100644 --- a/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts +++ b/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts @@ -69,7 +69,7 @@ import { useFilterUtilities } from '~/lib/viewer/composables/filtering/filtering import { useFilteringSetup } from '~/lib/viewer/composables/filtering/setup' import { useHighlightingPostSetup, - getGlobalHighlightExtension + getHighlightExtension } from '~/lib/viewer/composables/setup/highlighting' function useViewerLoadCompleteEventHandler() { @@ -582,7 +582,7 @@ function useViewerFiltersIntegration() { } state.ui.highlightedObjectIds.value = [] - const highlightExtension = getGlobalHighlightExtension() + const highlightExtension = getHighlightExtension(instance) if (highlightExtension) { highlightExtension.clearSelection() } From 3585b69b180d50ec864fd931580e28c0091022af Mon Sep 17 00:00:00 2001 From: andrewwallacespeckle Date: Fri, 19 Sep 2025 14:53:24 +0100 Subject: [PATCH 5/5] Remove getHighlightExtension --- .../lib/viewer/composables/setup/filters.ts | 4 ++-- .../lib/viewer/composables/setup/highlighting.ts | 12 ++---------- .../lib/viewer/composables/setup/postSetup.ts | 4 ++-- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/frontend-2/lib/viewer/composables/setup/filters.ts b/packages/frontend-2/lib/viewer/composables/setup/filters.ts index 719001185..cb6b8e5f7 100644 --- a/packages/frontend-2/lib/viewer/composables/setup/filters.ts +++ b/packages/frontend-2/lib/viewer/composables/setup/filters.ts @@ -6,7 +6,7 @@ import { watchTriggerable } from '@vueuse/core' import { useInjectedViewerState } from '~/lib/viewer/composables/setup' import { useOnViewerLoadComplete } from '~/lib/viewer/composables/viewer' import { useFilteringDataStore } from '~/lib/viewer/composables/filtering/dataStore' -import { getHighlightExtension } from '~/lib/viewer/composables/setup/highlighting' +import { HighlightExtension } from '~/lib/viewer/composables/setup/highlighting' /** * Setup composable for filter-related state @@ -68,7 +68,7 @@ export const useManualFilteringPostSetup = () => { */ const preserveSelectionHighlightFilter = (filterFn: () => T): T => { const selectionExtension = instance.getExtension(SelectionExtension) - const highlightExtension = getHighlightExtension(instance) + const highlightExtension = instance.getExtension(HighlightExtension) // 1. SAVE current state from viewer extensions const selectedObjects = selectionExtension diff --git a/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts b/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts index df6bedbb5..ad467bc8b 100644 --- a/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts +++ b/packages/frontend-2/lib/viewer/composables/setup/highlighting.ts @@ -15,7 +15,7 @@ import { ViewerRenderPageType } from '~/lib/viewer/helpers/state' * Highlighting extension that replicates LegacyViewer's HighlightExtension * Uses SelectionExtension but disables default events for UI-only highlighting */ -class HighlightExtension extends SelectionExtension { +export class HighlightExtension extends SelectionExtension { public constructor(viewer: IViewer, cameraProvider: CameraController) { super(viewer, cameraProvider) @@ -38,14 +38,6 @@ class HighlightExtension extends SelectionExtension { } } -/** - * Get the highlight extension instance from the viewer. - * The extension is created once during setup and then retrieved everywhere else. - */ -export const getHighlightExtension = (instance: IViewer): HighlightExtension | null => { - return instance.getExtension(HighlightExtension) -} - /** * Post-setup integration that sets up highlighting extension and watches state * This should only be called once during post-setup after the viewer is initialized. @@ -63,7 +55,7 @@ export const useHighlightingPostSetup = () => { instance.createExtension(HighlightExtension) // Get the highlighting extension instance - const getHighlightExtensionInstance = () => getHighlightExtension(instance) + const getHighlightExtensionInstance = () => instance.getExtension(HighlightExtension) useOnViewerLoadComplete( ({ isInitial }) => { diff --git a/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts b/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts index 269e95237..d0d762717 100644 --- a/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts +++ b/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts @@ -69,7 +69,7 @@ import { useFilterUtilities } from '~/lib/viewer/composables/filtering/filtering import { useFilteringSetup } from '~/lib/viewer/composables/filtering/setup' import { useHighlightingPostSetup, - getHighlightExtension + HighlightExtension } from '~/lib/viewer/composables/setup/highlighting' function useViewerLoadCompleteEventHandler() { @@ -582,7 +582,7 @@ function useViewerFiltersIntegration() { } state.ui.highlightedObjectIds.value = [] - const highlightExtension = getHighlightExtension(instance) + const highlightExtension = instance.getExtension(HighlightExtension) if (highlightExtension) { highlightExtension.clearSelection() }