Files
speckle-server/packages/frontend-2/components/viewer/models/Panel.vue
T
2025-08-12 16:49:01 +01:00

434 lines
12 KiB
Vue

<template>
<div class="select-none h-full">
<ViewerCompareChangesPanel
v-if="subView === 'diff'"
:clear-on-back="false"
@close="handleDiffClose"
/>
<ViewerModelsVersions
v-else-if="subView === 'versions'"
:expanded-model-id="expandedModelId"
@close="handleVersionsClose"
/>
<ViewerLayoutSidePanel v-else>
<template #title>
<span v-if="objects.length === 1">Detached object</span>
<span v-else-if="objects.length > 1">Detached objects</span>
<span v-else>Models</span>
</template>
<template #actions>
<ViewerModelsActions
v-if="!hasObjects"
:hide-versions="resourceItems.length === 0 && objects.length === 0"
@show-versions="subView = ModelsSubView.Versions"
@add-model="showAddModel = true"
/>
</template>
<div class="flex flex-col h-full">
<template v-if="resourceItems.length || objects.length">
<!-- Detached Objects Section -->
<div v-if="objects.length > 0">
<ViewerModelsDetachedObjectCard
v-for="object in objects"
:key="object.objectId"
:object-id="object.objectId"
/>
</div>
<!-- Sticky Header Area (outside virtual list) -->
<div v-if="stickyHeader" class="sticky top-0 z-20 h-16">
<ViewerModelsCard
:model="stickyHeader!.model"
:version-id="stickyHeader!.versionId"
:is-expanded="expandedModels.has(stickyHeader!.model.id)"
@toggle-expansion="toggleModelExpansion(stickyHeader!.model.id)"
@show-versions="handleShowVersions"
@show-diff="handleShowDiff"
/>
</div>
<div
class="flex-1 simple-scrollbar overflow-x-hidden"
data-virtual-list-container
v-bind="containerProps"
@scroll="handleScroll"
>
<div v-bind="wrapperProps">
<div
v-for="{ data: item } in virtualList"
:key="item.id"
:data-item-id="item.id"
class="group first:hidden"
>
<!-- Model Header -->
<template v-if="item.type === 'model-header'">
<div class="bg-foundation h-16">
<ViewerModelsCard
:model="getModelFromItem(item)"
:version-id="getVersionIdFromItem(item)"
:is-expanded="expandedModels.has(item.modelId)"
@toggle-expansion="toggleModelExpansion(item.modelId)"
@show-versions="handleShowVersions"
@show-diff="handleShowDiff"
/>
</div>
</template>
<!-- Tree Item -->
<template v-else-if="item.type === 'tree-item'">
<ViewerModelsVirtualTreeItem
:item="item"
@toggle-expansion="toggleTreeItemExpansion"
@item-click="handleItemClick"
/>
</template>
</div>
</div>
</div>
</template>
<!-- Empty State -->
<div
v-else
class="flex flex-col items-center justify-center gap-4 h-full -mt-8"
>
<IllustrationEmptystateModels />
<span class="text-body-xs text-foreground-2">No models loaded, yet.</span>
<FormButton @click="showAddModel = true">Add model</FormButton>
</div>
</div>
</ViewerLayoutSidePanel>
<ViewerModelsAddDialog v-model:open="showAddModel" />
</div>
</template>
<script setup lang="ts">
import {
useInjectedViewerLoadedResources,
useInjectedViewer,
useInjectedViewerState
} from '~~/lib/viewer/composables/setup'
import { ModelsSubView, type ExplorerNode } from '~~/lib/viewer/helpers/sceneExplorer'
import type { ViewerLoadedResourcesQuery } from '~~/lib/common/generated/gql/graphql'
import type { Get } from 'type-fest'
import { useDiffUtilities, useSelectionUtilities } from '~~/lib/viewer/composables/ui'
import {
useTreeManagement,
type UnifiedVirtualItem
} from '~~/lib/viewer/composables/tree'
import { useVirtualList, useDebounceFn } from '@vueuse/core'
type ModelItem = NonNullable<Get<ViewerLoadedResourcesQuery, 'project.models.items[0]'>>
const subView = defineModel<ModelsSubView>('subView', { default: ModelsSubView.Main })
const expandedModelId = ref<string | null>(null)
const showAddModel = ref(false)
const expandedNodes = ref<Set<string>>(new Set())
const expandedModels = ref<Set<string>>(new Set())
const disableScrollOnNextSelection = ref(false)
const stickyHeader = ref<{ model: ModelItem; versionId: string } | null>(null)
const scrollTop = ref(0)
const { resourceItems, modelsAndVersionIds, objects } =
useInjectedViewerLoadedResources()
const {
metadata: { worldTree }
} = useInjectedViewer()
const {
resources: {
response: { resourceItems: stateResourceItems }
},
ui: { diff: diffState }
} = useInjectedViewerState()
const {
objects: selectedObjects,
addToSelection,
clearSelection,
removeFromSelection
} = useSelectionUtilities()
const { diffModelVersions, endDiff } = useDiffUtilities()
const {
flattenModelTree,
getRootNodesForModel,
findObjectInNodes,
expandNodesToShowObject,
treeStateManager
} = useTreeManagement()
const hasObjects = computed(() => objects.value.length > 0)
const unifiedVirtualItems = computed(() => {
return treeStateManager.getUnifiedVirtualItems(
modelsAndVersionIds.value,
expandedModels.value,
expandedNodes.value,
selectedObjects.value,
worldTree.value || null,
stateResourceItems.value as { objectId: string; modelId?: string }[],
getRootNodesForModel,
flattenModelTree
)
})
const {
list: virtualList,
containerProps,
wrapperProps
} = useVirtualList(unifiedVirtualItems, {
itemHeight: (index) => {
const item = unifiedVirtualItems.value[index]
return item?.type === 'model-header' ? 64 : 40
},
overscan: 20
})
// Calculate header positions precisely - memoized for performance
const modelHeaderPositions = computed(() => {
const headers: Array<{
index: number
model: ModelItem
versionId: string
position: number
}> = []
let cumulativeHeight = 0
for (let i = 0; i < unifiedVirtualItems.value.length; i++) {
const item = unifiedVirtualItems.value[i]
const itemHeight = item.type === 'model-header' ? 64 : 40
if (item.type === 'model-header') {
const data = item.data as { model: ModelItem; versionId: string }
headers.push({
index: i,
model: data.model,
versionId: data.versionId,
position: cumulativeHeight
})
}
cumulativeHeight += itemHeight
}
return headers
})
const hasDiffActive = computed(() => {
return !!(diffState.oldVersion.value && diffState.newVersion.value)
})
const handleShowVersions = (modelId: string) => {
expandedModelId.value = modelId
subView.value = ModelsSubView.Versions
}
const handleShowDiff = async (modelId: string, versionA: string, versionB: string) => {
await diffModelVersions(modelId, versionA, versionB)
expandedModelId.value = modelId
subView.value = ModelsSubView.Diff
}
const handleVersionsClose = () => {
subView.value = ModelsSubView.Main
expandedModelId.value = null
}
const handleDiffClose = async () => {
await endDiff()
subView.value = ModelsSubView.Versions
}
const toggleModelExpansion = (modelId: string) => {
if (expandedModels.value.has(modelId)) {
expandedModels.value.delete(modelId)
} else {
expandedModels.value.add(modelId)
}
}
const toggleTreeItemExpansion = (itemId: string) => {
if (expandedNodes.value.has(itemId)) {
expandedNodes.value.delete(itemId)
} else {
expandedNodes.value.add(itemId)
}
}
const handleItemClick = (
item: UnifiedVirtualItem,
event: MouseEvent | KeyboardEvent
) => {
if (item.type !== 'tree-item') return
const node = item.data as ExplorerNode
const speckleData = node.raw
if (!speckleData?.id) return
const isCurrentlySelected = selectedObjects.value.find((o) => o.id === speckleData.id)
if (isCurrentlySelected && !event.shiftKey) {
if (item.hasChildren && !item.isExpanded) {
toggleTreeItemExpansion(item.id)
}
return
}
if (isCurrentlySelected && event.shiftKey) {
disableScrollOnNextSelection.value = true
removeFromSelection(speckleData)
return
}
// Disable scroll for this user-initiated selection
disableScrollOnNextSelection.value = true
if (!event.shiftKey) clearSelection()
addToSelection(speckleData)
if (item.hasChildren && !item.isExpanded) {
toggleTreeItemExpansion(item.id)
}
}
const getModelFromItem = (item: UnifiedVirtualItem): ModelItem => {
if (item.type === 'model-header') {
return (item.data as { model: ModelItem; versionId: string }).model
}
return {} as ModelItem
}
const getVersionIdFromItem = (item: UnifiedVirtualItem): string => {
if (item.type === 'model-header') {
return (item.data as { model: ModelItem; versionId: string }).versionId
}
return ''
}
const scrollToSelectedItem = (objectId: string) => {
nextTick(() => {
const itemIndex = unifiedVirtualItems.value.findIndex(
(item) =>
item.type === 'tree-item' && (item.data as ExplorerNode).raw?.id === objectId
)
if (itemIndex !== -1) {
const container = containerProps.ref.value
if (container) {
const containerHeight = container.clientHeight
const itemHeight = 40
const totalOffset = itemIndex * itemHeight
const centerOffset = containerHeight / 2 - itemHeight / 2
const scrollPosition = Math.max(0, totalOffset - centerOffset)
container.scrollTo({
top: scrollPosition
})
}
}
})
}
const handleSelectionChange = useDebounceFn(
(newSelection: typeof selectedObjects.value) => {
if (newSelection.length > 0 && !disableScrollOnNextSelection.value) {
for (const selectedObj of newSelection) {
for (const { model } of modelsAndVersionIds.value) {
const modelRootNodes = getRootNodesForModel(
model.id,
worldTree.value || null,
stateResourceItems.value as { objectId: string; modelId?: string }[],
modelsAndVersionIds.value
)
const containsObject = findObjectInNodes(modelRootNodes, selectedObj.id)
if (containsObject) {
expandedModels.value.add(model.id)
expandNodesToShowObject(
modelRootNodes,
selectedObj.id,
model.id,
expandedNodes.value
)
scrollToSelectedItem(selectedObj.id)
break
}
}
break
}
}
disableScrollOnNextSelection.value = false
},
100
)
// Simple scroll tracking - just switch headers
const handleScroll = (e: Event) => {
const container = e.target as HTMLElement
if (!container) return
scrollTop.value = container.scrollTop
const modelHeaders = modelHeaderPositions.value
if (modelHeaders.length === 0) return
// Find the current active header
let currentHeaderIndex = 0
for (let i = modelHeaders.length - 1; i >= 0; i--) {
if (modelHeaders[i].position <= scrollTop.value) {
currentHeaderIndex = i
break
}
}
const currentHeader = modelHeaders[currentHeaderIndex]
// Simply update sticky header
if (currentHeader) {
stickyHeader.value = {
model: currentHeader.model,
versionId: currentHeader.versionId
}
}
}
watch(selectedObjects, handleSelectionChange, { deep: true })
watch(subView, (newSubView) => {
if (newSubView === ModelsSubView.Main) {
expandedModelId.value = null
}
})
watch(hasDiffActive, (isActive) => {
if (isActive && subView.value !== ModelsSubView.Diff) {
subView.value = ModelsSubView.Diff
}
})
// Initialize and update sticky header when models change
watch(
unifiedVirtualItems,
(items) => {
if (items.length > 0) {
const firstModelHeader = items.find((item) => item.type === 'model-header')
if (firstModelHeader) {
const data = firstModelHeader.data as { model: ModelItem; versionId: string }
// Always update to the current first model (handles new models being added)
stickyHeader.value = {
model: data.model,
versionId: data.versionId
}
}
} else {
stickyHeader.value = null
}
},
{ immediate: true }
)
</script>