Merge branch 'feature/initial-viewer-ui-updates' of github.com:specklesystems/speckle-server into feature/initial-viewer-ui-updates

This commit is contained in:
Mike Tasset
2025-07-30 21:00:27 +02:00
2 changed files with 199 additions and 64 deletions
@@ -1,17 +1,16 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<div class="relative border-b border-outline-3">
<div
@mouseenter="highlightObject"
@mouseleave="unhighlightObject"
@focusin="highlightObject"
@focusout="unhighlightObject"
@click="selectObject"
@keydown.enter="selectObject"
>
<div>
<!-- Model Header -->
<div
class="group flex items-center px-1 py-3 select-none cursor-pointer hover:bg-highlight-1"
@mouseenter="highlightObject"
@mouseleave="unhighlightObject"
@focusin="highlightObject"
@focusout="unhighlightObject"
@click="selectObject"
@keydown.enter="selectObject"
>
<button
class="group-hover:opacity-100 hover:bg-highlight-3 rounded-md h-5 w-4 flex items-center justify-center shrink-0"
@@ -105,7 +104,7 @@
<!-- Scene Explorer Content -->
<div
v-if="isExpanded && rootNodeChildren.length"
class="relative flex flex-col gap-y-2"
class="relative flex flex-col gap-y-2 overflow-y-auto"
>
<div v-for="(childNode, idx) in rootNodeChildren" :key="idx">
<ViewerModelsTreeItem
@@ -113,6 +112,7 @@
:sub-header="'Model content'"
:expand-level="expandLevel"
:manual-expand-level="manualExpandLevel"
:force-expanded-node-ids="forceExpandedNodeIds"
:is-descendant-of-selected="false"
@expanded="(e: number) => $emit('expanded', e)"
/>
@@ -164,7 +164,7 @@ const { getTooltipProps } = useSmartTooltipDelay()
const { highlightObjects, unhighlightObjects } = useHighlightedObjectsUtilities()
const { hideObjects, showObjects, isolateObjects, unIsolateObjects } =
useFilterUtilities()
const { setSelectionFromObjectIds } = useSelectionUtilities()
const { setSelectionFromObjectIds, objects } = useSelectionUtilities()
const { items } = useInjectedViewerRequestedResources()
const {
viewer: {
@@ -193,6 +193,9 @@ const { load: loadLatestVersion } = useLoadLatestVersion({
const isExpanded = ref(false)
const showActionsMenu = ref(false)
// Track expansion state for auto-expanding selected objects
const forceExpandedNodeIds = ref<Set<string>>(new Set())
const IconEye = resolveComponent('IconEye')
const IconEyeClosed = resolveComponent('IconEyeClosed')
@@ -410,4 +413,68 @@ const onActionChosen = (params: { item: LayoutMenuItem }) => {
break
}
}
const expandToShowSelectedObjects = (selectedIds: string[]) => {
// Find paths to all selected objects in this model's tree
const pathsToExpand = findPathsToSelectedObjects(selectedIds)
if (pathsToExpand.length > 0) {
// Expand the model itself
if (!isExpanded.value) {
isExpanded.value = true
}
// Clear previous force-expanded nodes and set new ones
const nodeIdsToExpand = pathsToExpand.flat()
forceExpandedNodeIds.value = new Set(nodeIdsToExpand)
}
}
const findPathsToSelectedObjects = (selectedIds: string[]): string[][] => {
const paths: string[][] = []
for (const rootNode of props.rootNodes) {
const nodePaths = findPathsInNode(rootNode, selectedIds, [])
paths.push(...nodePaths)
}
return paths
}
const findPathsInNode = (
node: ExplorerNode,
selectedIds: string[],
currentPath: string[]
): string[][] => {
const paths: string[][] = []
const nodeId = node.raw?.id || node.guid || ''
const newPath = nodeId ? [...currentPath, nodeId] : currentPath
if (nodeId && selectedIds.includes(nodeId)) {
paths.push(newPath)
}
if (node.children) {
for (const child of node.children) {
const childPaths = findPathsInNode(child, selectedIds, newPath)
paths.push(...childPaths)
}
}
return paths
}
// Auto-expand tree to show selected objects
watch(
() => objects.value,
(selectedObjects) => {
const selectedIds = selectedObjects.map((obj: { id: string }) => obj.id)
if (selectedObjects.length > 0) {
expandToShowSelectedObjects(selectedIds)
} else {
forceExpandedNodeIds.value = new Set()
}
},
{ deep: true }
)
</script>
@@ -4,6 +4,7 @@
<div :class="{ 'opacity-60': shouldShowDimmed }">
<!-- Header -->
<div
ref="headerElement"
class="group flex items-center justify-between w-full p-2 pr-1 cursor-pointer"
:class="getBackgroundClass"
@click.stop="(e:MouseEvent) => setSelection(e)"
@@ -114,12 +115,13 @@
:depth="depth + 1"
:expand-level="props.expandLevel"
:manual-expand-level="manualExpandLevel"
:force-expanded-node-ids="props.forceExpandedNodeIds"
:parent="treeItem"
:is-descendant-of-selected="isSelected || isChildOfSelected"
@expanded="(e) => $emit('expanded', e)"
/>
</div>
<div v-if="itemCount <= singleCollectionItems.length" class="mb-2">
<div v-if="itemCount < singleCollectionItems.length">
<FormButton
size="sm"
color="outline"
@@ -166,6 +168,7 @@ const props = withDefaults(
header?: string | null
subHeader?: string | null
isDescendantOfSelected?: boolean
forceExpandedNodeIds?: Set<string>
}>(),
{ depth: 0, header: null, subHeader: null, isDescendantOfSelected: false }
)
@@ -312,33 +315,6 @@ const isAllowedType = (node: ExplorerNode) => {
const unfold = ref(false)
// NOTE: not happy with how unfolding and collapsing panned out :(
// it works, but requiring two different props... phew.
watch(
() => props.expandLevel,
(newVal) => {
if (isSingleCollection.value || isMultipleCollection.value) {
unfold.value = newVal >= props.depth
}
// if (newVal > oldVal) unfold.value = true
// else if (newVal <= props.depth) unfold.value = false
}
)
watch(
() => props.manualExpandLevel,
(newVal, oldVal) => {
if (!(isSingleCollection.value || isMultipleCollection.value)) return
if (
newVal < oldVal &&
unfold.value &&
(isSingleCollection.value || isMultipleCollection.value) &&
props.depth > newVal
)
unfold.value = false
}
)
// Note: we need to emit a manual unfold event with the current depth so we can set it upstream
// for the collapse/unfold functionality
const manualUnfoldToggle = () => {
@@ -367,32 +343,6 @@ const getBackgroundClass = computed(() => {
return 'bg-foundation hover:bg-highlight-1'
})
const setSelection = (e: MouseEvent) => {
if (isSelected.value && !e.shiftKey) {
// If already selected, try to expand first before deselecting
if ((isSingleCollection.value || isMultipleCollection.value) && !unfold.value) {
unfold.value = true
emit('expanded', props.depth)
return
}
// Only deselect if can't expand or already expanded
clearSelection()
return
}
if (isSelected.value && e.shiftKey) {
removeFromSelection(rawSpeckleData.value)
return
}
if (!e.shiftKey) clearSelection()
addToSelection(rawSpeckleData.value)
// Auto-expand when selecting if it has children
if ((isSingleCollection.value || isMultipleCollection.value) && !unfold.value) {
unfold.value = true
emit('expanded', props.depth)
}
}
const highlightObject = () => {
highlightObjects(getTargetObjectIds(rawSpeckleData.value))
}
@@ -444,4 +394,122 @@ const isolateOrUnisolateObject = () => {
unIsolateObjects(ids)
}
const setSelection = (e: MouseEvent) => {
disableScrollOnNextSelection.value = true
if (isSelected.value && !e.shiftKey) {
// If already selected, try to expand if possible, but don't deselect
if ((isSingleCollection.value || isMultipleCollection.value) && !unfold.value) {
unfold.value = true
emit('expanded', props.depth)
}
disableScrollOnNextSelection.value = false
return
}
if (isSelected.value && e.shiftKey) {
removeFromSelection(rawSpeckleData.value)
disableScrollOnNextSelection.value = false
return
}
if (!e.shiftKey) clearSelection()
addToSelection(rawSpeckleData.value)
// Auto-expand when selecting if it has children
if ((isSingleCollection.value || isMultipleCollection.value) && !unfold.value) {
unfold.value = true
emit('expanded', props.depth)
}
}
const ensureSelectedItemsVisible = () => {
const selectedIds = objects.value.map((obj: { id: string }) => obj.id)
if (selectedIds.length === 0) return
// Check if any selected items are in this container but beyond current pagination
const allItems = singleCollectionItems.value
const selectedIndices = allItems
.map((item, index) => ({ item, index }))
.filter(({ item }) => {
const itemId = item.rawNode.raw?.id
return itemId && selectedIds.includes(itemId)
})
.map(({ index }) => index)
if (selectedIndices.length > 0) {
const maxSelectedIndex = Math.max(...selectedIndices)
// Ensure itemCount is high enough to show the selected item
if (maxSelectedIndex >= itemCount.value) {
itemCount.value = Math.max(maxSelectedIndex + 1, itemCount.value)
}
}
}
const checkAndExpand = () => {
const nodeId = rawSpeckleData.value.id
const shouldForceExpand = props.forceExpandedNodeIds?.has(nodeId)
if (
(isSingleCollection.value || isMultipleCollection.value) &&
(props.expandLevel >= props.depth || shouldForceExpand)
) {
unfold.value = true
ensureSelectedItemsVisible()
}
}
const headerElement = ref<HTMLElement>()
const disableScrollOnNextSelection = ref(false)
onMounted(() => {
checkAndExpand()
const nodeId = rawSpeckleData.value.id
const isForceExpanded = props.forceExpandedNodeIds?.has(nodeId)
if (
isSelected.value &&
isForceExpanded &&
headerElement.value &&
!disableScrollOnNextSelection.value
) {
headerElement.value.scrollIntoView({
behavior: 'instant',
block: 'center'
})
}
disableScrollOnNextSelection.value = false
})
watch(
() => props.forceExpandedNodeIds,
() => {
checkAndExpand()
},
{ deep: true }
)
watch(
() => isSelected.value,
(newIsSelected, oldIsSelected) => {
if (newIsSelected && !oldIsSelected) {
const nodeId = rawSpeckleData.value.id
const isForceExpanded = props.forceExpandedNodeIds?.has(nodeId)
if (
isForceExpanded &&
headerElement.value &&
!disableScrollOnNextSelection.value
) {
headerElement.value.scrollIntoView({
behavior: 'instant',
block: 'center'
})
}
disableScrollOnNextSelection.value = false
}
}
)
</script>