Files
speckle-server/packages/frontend-2/components/viewer/selection/Sidebar.vue
T
2025-08-12 14:39:48 +01:00

301 lines
8.3 KiB
Vue

<template>
<ViewerCommentsPortalOrDiv class="relative" to="bottomPanel">
<ViewerControlsRight
v-if="isGreaterThanSm"
:sidebar-open="sidebarOpen && shouldRenderSidebar"
:sidebar-width="sidebarWidth"
/>
<ViewerSidebar
v-if="shouldRenderSidebar"
:open="sidebarOpen"
@close="onClose"
@width-change="sidebarWidth = $event"
>
<template #title>
<div class="flex items-center gap-x-2">
<p>Selected</p>
<CommonBadge v-if="objects.length > 1" rounded>
{{ objects.length }}
</CommonBadge>
</div>
</template>
<template #actions>
<div class="flex gap-x-0.5 items-center">
<div
v-tippy="getTooltipProps(isHidden ? 'Show' : 'Hide', { placement: 'top' })"
>
<FormButton
color="subtle"
:icon-left="isHidden ? iconEyeClosed : iconEye"
hide-text
@click.stop="hideOrShowSelection"
/>
</div>
<div
v-tippy="
getTooltipProps(isIsolated ? 'Unisolate' : 'Isolate', {
placement: 'top'
})
"
>
<FormButton
color="subtle"
:icon-left="isIsolated ? iconViewerUnisolate : iconViewerIsolate"
hide-text
@click.stop="isolateOrUnisolateSelection"
/>
</div>
<LayoutMenu
v-model:open="showSubMenu"
:menu-id="menuId"
:items="actionsItems"
:custom-menu-items-classes="['!w-42']"
@click.stop.prevent
@chosen="onActionChosen"
>
<FormButton
hide-text
color="subtle"
:icon-left="settingsIcon"
@click="showSubMenu = !showSubMenu"
/>
</LayoutMenu>
</div>
</template>
<div class="space-y-1">
<ViewerSelectionObject
v-for="(object, index) in objectsLimited"
:key="(object.id as string)"
:object="object"
:root="true"
:unfold="index === 0 && !isSmallerOrEqualSm"
/>
</div>
<div v-if="itemCount <= objects.length" class="mb-2">
<FormButton size="sm" text full-width @click="itemCount += 10">
View more ({{ objects.length - itemCount }})
</FormButton>
</div>
<template #footer>
<p class="text-foreground-2 text-body-3xs">
Hold "shift" to select multiple objects
</p>
</template>
</ViewerSidebar>
</ViewerCommentsPortalOrDiv>
</template>
<script setup lang="ts">
import { onKeyStroke, useBreakpoints } from '@vueuse/core'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { getTargetObjectIds } from '~~/lib/object-sidebar/helpers'
import { containsAll } from '~~/lib/common/helpers/utils'
import { useFilterUtilities, useSelectionUtilities } from '~~/lib/viewer/composables/ui'
import { uniqWith } from 'lodash-es'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
import { modelRoute } from '~/lib/common/helpers/route'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import type { ConcreteComponent } from 'vue'
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
enum ActionTypes {
OpenInNewTab = 'open-in-new-tab'
}
const {
projectId,
viewer: {
metadata: { filteringState }
},
ui: { diff, measurement, threads },
urlHashState: { focusedThreadId }
} = useInjectedViewerState()
const { objects, clearSelection } = useSelectionUtilities()
const { hideObjects, showObjects, isolateObjects, unIsolateObjects } =
useFilterUtilities()
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
const breakpoints = useBreakpoints(TailwindBreakpoints)
const isGreaterThanSm = breakpoints.greater('sm')
const menuId = useId()
const mp = useMixpanel()
const { getTooltipProps } = useSmartTooltipDelay()
const itemCount = ref(20)
const sidebarOpen = ref(false)
const sidebarWidth = ref(280)
const showSubMenu = ref(false)
const iconViewerUnisolate = resolveComponent('IconViewerUnisolate') as ConcreteComponent
const iconViewerIsolate = resolveComponent('IconViewerIsolate') as ConcreteComponent
const iconEyeClosed = resolveComponent('IconEyeClosed') as ConcreteComponent
const iconEye = resolveComponent('IconEye') as ConcreteComponent
const settingsIcon = resolveComponent('IconThreeDots') as ConcreteComponent
const objectsUniqueByAppId = computed(() => {
if (!diff.enabled.value) return objects.value
return uniqWith(objects.value, (a, b) => {
return a.applicationId === b.applicationId
})
})
const shouldRenderSidebar = computed(() => {
return (!isSmallerOrEqualSm.value || sidebarOpen.value) && !measurement.enabled.value
})
const objectsLimited = computed(() => {
return objectsUniqueByAppId.value.slice(0, itemCount.value)
})
const hiddenObjects = computed(() => filteringState.value?.hiddenObjects)
const isolatedObjects = computed(() => filteringState.value?.isolatedObjects)
const allTargetIds = computed(() => {
const ids = []
for (const obj of objects.value) {
ids.push(...getTargetObjectIds(obj))
}
return ids
})
const isHidden = computed(() => {
if (!hiddenObjects.value) return false
return containsAll(allTargetIds.value, hiddenObjects.value)
})
const isIsolated = computed(() => {
if (!isolatedObjects.value) return false
return containsAll(allTargetIds.value, isolatedObjects.value)
})
const actionsItems = computed<LayoutMenuItem[][]>(() => [
[
{
title:
allTargetIds.value.length > 1
? 'Open objects in new tab'
: 'Open object in new tab',
id: ActionTypes.OpenInNewTab
}
]
])
const selectionLink = computed(() => {
return modelRoute(projectId.value, allTargetIds.value.join(','))
})
const onActionChosen = (params: { item: LayoutMenuItem; event: MouseEvent }) => {
const { item } = params
switch (item.id) {
case ActionTypes.OpenInNewTab:
window.open(selectionLink.value, '_blank')
break
}
}
const hideOrShowSelection = () => {
if (!isHidden.value) {
hideObjects(allTargetIds.value)
mp.track('Viewer Action', {
type: 'action',
name: 'selection',
action: 'hide'
})
return
}
showObjects(allTargetIds.value)
mp.track('Viewer Action', {
type: 'action',
name: 'selection',
action: 'show'
})
}
const isolateOrUnisolateSelection = () => {
if (isIsolated.value) {
unIsolateObjects(allTargetIds.value)
mp.track('Viewer Action', {
type: 'action',
name: 'selection',
action: 'unisolate'
})
} else {
isolateObjects(allTargetIds.value)
mp.track('Viewer Action', {
type: 'action',
name: 'selection',
action: 'isolate'
})
}
}
const trackAndClearSelection = () => {
clearSelection()
mp.track('Viewer Action', {
type: 'action',
name: 'selection',
action: 'clear',
source: 'sidebar-x-button'
})
}
const onClose = () => {
sidebarOpen.value = false
trackAndClearSelection()
}
const forceClose = () => {
sidebarOpen.value = false
}
onKeyStroke('Escape', () => {
// Cleareance of any vis/iso state coming from here should happen in clearSelection()
// Note: we're not using the trackAndClearSelection method beacuse
// we want to track whether people press buttons or keys
clearSelection()
mp.track('Viewer Action', {
type: 'action',
name: 'selection',
action: 'clear',
source: 'keypress-escape'
})
})
watch(
[
() => objects.value.length,
() => focusedThreadId.value,
() => threads.openThread.newThreadEditor.value,
() => isSmallerOrEqualSm.value
],
([objLen, threadId, isNewThreadEditorOpen, isSmSm]) => {
// Close sidebar if a thread is focused
if (threadId) {
sidebarOpen.value = false
return
}
// Close sidebar if new thread editor is open and screen is small
if (isNewThreadEditorOpen && isSmSm) {
sidebarOpen.value = false
return
}
// Open sidebar if objects are selected and no thread is focused
if (objLen !== 0 && !threadId) {
sidebarOpen.value = true
} else if (objLen === 0) {
sidebarOpen.value = false
}
}
)
defineExpose({
forceClose
})
</script>