Merge remote-tracking branch 'origin/main' into adam/add-ol2-options

This commit is contained in:
Adam Hathcock
2025-09-23 09:44:31 +01:00
41 changed files with 730 additions and 296 deletions
@@ -1,20 +1,14 @@
<template>
<LayoutDialog v-model:open="open" max-width="sm">
<template #header>Share dashboard</template>
<h4 class="text-body-xs font-medium text-foreground mb-1">Dashboard URL</h4>
<FormClipboardInput :value="dashboardUrl" />
<div v-if="canCreateToken">
<hr class="my-4 border-outline-3" />
<div class="flex items-center justify-between">
<div>
<p class="text-body-xs font-medium text-foreground">Enable public access</p>
<p class="text-body-2xs text-foreground-2">Anyone with the link can view</p>
</div>
<FormSwitch v-model="enablePublicUrl" name="isPublic" :show-label="false" />
<div class="flex items-center justify-between">
<div>
<p class="text-body-xs font-medium text-foreground">Enable public access</p>
<p class="text-body-2xs text-foreground-2">Anyone with the link can view</p>
</div>
<FormClipboardInput v-if="enablePublicUrl" class="mt-3" :value="shareUrl" />
<FormSwitch v-model="enablePublicUrl" name="isPublic" :show-label="false" />
</div>
<FormClipboardInput v-if="enablePublicUrl" class="mt-3" :value="shareUrl" />
</LayoutDialog>
</template>
@@ -23,6 +17,11 @@ import { dashboardRoute } from '~~/lib/common/helpers/route'
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { graphql } from '~~/lib/common/generated/gql'
import { useQuery, useMutation } from '@vue/apollo-composable'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import {
convertThrowIntoFetchResult,
getFirstErrorMessage
} from '~~/lib/common/helpers/graphql'
const dashboardsDialogSharePermissionsQuery = graphql(`
query DashboardsSharDialogPermissions($id: String!) {
@@ -33,11 +32,6 @@ const dashboardsDialogSharePermissionsQuery = graphql(`
content
revoked
}
permissions {
canCreateToken {
...FullPermissionCheckResult
}
}
}
}
`)
@@ -91,6 +85,7 @@ const { result, refetch } = useQuery(dashboardsDialogSharePermissionsQuery, () =
const { mutate: createToken } = useMutation(dashboardsDialogShareTokenMutation)
const { mutate: disableToken } = useMutation(dashboardsDialogShareDisableTokenMutation)
const { mutate: enableToken } = useMutation(dashboardsDialogShareEnableTokenMutation)
const { triggerNotification } = useGlobalToast()
const isRevoked = computed(() => result.value?.dashboard?.shareLink?.revoked)
const shareLink = computed(() => result.value?.dashboard?.shareLink)
@@ -105,23 +100,12 @@ const shareUrl = computed(() => {
return url.toString()
})
const canCreateToken = computed(
() => result.value?.dashboard?.permissions?.canCreateToken?.authorized
)
const enablePublicUrl = computed({
get: () => !isRevoked.value && !!shareLink.value?.id,
set: (value: boolean) => {
onEnablePublicUrl(value)
}
})
const dashboardUrl = computed(() => {
if (!props.workspaceSlug || !props.dashboardId) return ''
return new URL(
dashboardRoute(props.workspaceSlug, props.dashboardId),
window.location.toString()
).toString()
})
const onEnablePublicUrl = async (value: boolean) => {
if (!props.dashboardId) return
@@ -129,7 +113,18 @@ const onEnablePublicUrl = async (value: boolean) => {
if (value) {
// If enabling and no share link exists, create one first
if (!shareLink.value?.id) {
await createToken({ dashboardId: props.dashboardId })
const result = await createToken({ dashboardId: props.dashboardId }).catch(
convertThrowIntoFetchResult
)
if (!result?.data?.dashboardMutations.share.id) {
const errMsg = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to enable public access',
description: errMsg
})
}
}
// Enable the share link
@@ -5,7 +5,6 @@
color="outline"
class="hidden sm:flex"
size="sm"
:disabled="!canCreateToken"
@click="shareDialogOpen = true"
>
Share
@@ -20,34 +19,11 @@
<script setup lang="ts">
import type { MaybeNullOrUndefined } from '@speckle/shared'
import { graphql } from '~~/lib/common/generated/gql'
import { useQuery } from '@vue/apollo-composable'
const dashboardsSharePermissionsQuery = graphql(`
query DashboardsSharePermissions($id: String!) {
dashboard(id: $id) {
id
permissions {
canCreateToken {
...FullPermissionCheckResult
}
}
}
}
`)
const props = defineProps<{
defineProps<{
id: MaybeNullOrUndefined<string>
workspaceSlug: MaybeNullOrUndefined<string>
}>()
const { result } = useQuery(dashboardsSharePermissionsQuery, {
id: props.id as string
})
const canCreateToken = computed(
() => result.value?.dashboard?.permissions?.canCreateToken?.authorized
)
const shareDialogOpen = ref(false)
</script>
@@ -15,7 +15,7 @@
{{ presentation?.title }}
</h1>
<LayoutMenu
<!-- <LayoutMenu
v-model:open="showMenu"
class="hidden md:block"
:items="menuItems"
@@ -26,14 +26,14 @@
<PresentationFloatingPanelButton @click="showMenu = !showMenu">
<LucideEllipsis class="size-4" />
</PresentationFloatingPanelButton>
</LayoutMenu>
</LayoutMenu> -->
</div>
</PresentationFloatingPanel>
</template>
<script setup lang="ts">
import { LucideArrowLeftToLine, LucidePanelLeft, LucideEllipsis } from 'lucide-vue-next'
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
import { LucideArrowLeftToLine, LucidePanelLeft } from 'lucide-vue-next'
// import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
import { graphql } from '~~/lib/common/generated/gql'
@@ -44,9 +44,9 @@ graphql(`
}
`)
enum MenuItems {
OpenInViewer = 'open-in-viewer'
}
// enum MenuItems {
// OpenInViewer = 'open-in-viewer'
// }
const emit = defineEmits<{
(e: 'toggleSidebar'): void
@@ -57,25 +57,25 @@ const isSidebarOpen = defineModel<boolean>('is-sidebar-open')
const {
response: { presentation }
} = useInjectedPresentationState()
const menuId = useId()
// const menuId = useId()
const showMenu = ref(false)
// const showMenu = ref(false)
const menuItems = computed<LayoutMenuItem[][]>(() => [
[
{
title: 'Open in viewer',
id: MenuItems.OpenInViewer
}
]
])
// const menuItems = computed<LayoutMenuItem[][]>(() => [
// [
// {
// title: 'Open in viewer',
// id: MenuItems.OpenInViewer
// }
// ]
// ])
const onActionChosen = (params: { item: LayoutMenuItem }) => {
const { item } = params
// const onActionChosen = (params: { item: LayoutMenuItem }) => {
// const { item } = params
switch (item.id) {
case MenuItems.OpenInViewer:
// Will be added soon
}
}
// switch (item.id) {
// case MenuItems.OpenInViewer:
// // Will be added soon
// }
// }
</script>
@@ -1,15 +1,22 @@
<template>
<aside
class="bg-foundation h-48 md:h-screen w-full md:w-64 xl:w-80 border-t md:border-t-0 md:border-l border-outline-3 py-5 px-4"
class="bg-foundation h-48 md:h-dvh w-full md:w-64 xl:w-80 border-t md:border-t-0 md:border-l border-outline-3 py-5 px-4"
>
<div class="hidden md:flex items-center justify-end space-x-0.5">
<FormButton
v-if="canUpdate"
:icon-left="LucidePencilLine"
color="subtle"
hide-text
@click="isSlideEditDialogOpen = true"
/>
<div
v-tippy="
canUpdateSlide ? undefined : 'You do not have permission to edit this slide'
"
>
<FormButton
v-if="canUpdate"
:disabled="!canUpdateSlide"
:icon-left="LucidePencilLine"
color="subtle"
hide-text
@click="isSlideEditDialogOpen = true"
/>
</div>
<FormButton
:icon-left="LucideX"
color="subtle"
@@ -43,6 +50,7 @@
<PresentationSlideEditDialog
v-model:open="isSlideEditDialogOpen"
:slide="currentSlide"
:workspace-id="workspace?.id"
/>
</aside>
</template>
@@ -71,17 +79,25 @@ graphql(`
...PresentationSlideEditDialog_SavedView
name
description
permissions {
canUpdate {
...FullPermissionCheckResult
}
}
}
`)
const {
ui: { slide: currentSlide },
response: { presentation }
response: { presentation, workspace }
} = useInjectedPresentationState()
const isSlideEditDialogOpen = ref(false)
const canUpdate = computed(() => {
return presentation.value?.permissions?.canUpdate
return presentation.value?.permissions?.canUpdate?.authorized
})
const canUpdateSlide = computed(() => {
return currentSlide.value?.permissions?.canUpdate.authorized
})
</script>
@@ -5,15 +5,27 @@
</div>
<aside
class="relative z-20 bg-foundation h-screen w-52 md:w-60 border-r border-outline-3 pt-3"
class="relative z-20 bg-foundation h-dvh w-52 md:w-60 border-r border-outline-3 pt-3"
>
<div class="flex flex-col h-full">
<section class="flex-shrink-0 flex items-center gap-2.5 px-3">
<WorkspaceAvatar :name="workspace?.name" :logo="workspace?.logo" />
<p class="text-body-xs text-foreground">
{{ workspace?.name }}
</p>
<UserAvatar size="sm" class="ml-auto flex-shrink-0" :user="activeUser" />
<section class="flex-shrink-0 flex items-center gap-3 px-3">
<NuxtLink
class="flex items-center gap-2 min-w-0 flex-1"
:to="workspaceRoute(workspace?.slug)"
>
<WorkspaceAvatar :name="workspace?.name" :logo="workspace?.logo" />
<div class="flex-1 min-w-0">
<p class="text-body-xs text-foreground truncate">
{{ workspace?.name }}
</p>
</div>
</NuxtLink>
<UserAvatar
v-if="isLoggedIn"
size="sm"
class="ml-auto flex-shrink-0"
:user="activeUser"
/>
</section>
<section
class="flex-1 flex justify-center simple-scrollbar overflow-y-auto mt-3 pb-3 px-3"
@@ -34,7 +46,7 @@
</template>
<script setup lang="ts">
import { loginRoute, registerRoute } from '~~/lib/common/helpers/route'
import { loginRoute, registerRoute, workspaceRoute } from '~~/lib/common/helpers/route'
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
import { graphql } from '~~/lib/common/generated/gql'
@@ -43,6 +55,7 @@ graphql(`
id
name
logo
slug
}
`)
@@ -1,6 +1,6 @@
<template>
<div class="relative">
<div class="h-screen w-screen flex flex-col md:flex-row relative">
<div class="h-dvh w-screen flex flex-col md:flex-row relative">
<PresentationHeader
v-if="!hideUi"
v-model:is-sidebar-open="isLeftSidebarOpen"
@@ -21,8 +21,10 @@
/>
<PresentationSlideIndicator
v-if="!isViewerLoading"
:show-slide-list="!isLeftSidebarOpen"
class="absolute top-1/2 translate-y-[calc(-50%+25px)] z-20"
:class="[isLeftSidebarOpen ? 'lg:left-[15.75rem] hidden md:block' : 'left-4']"
:class="[isLeftSidebarOpen ? 'lg:left-[14.75rem] hidden md:block' : 'left-0']"
/>
<PresentationSpeckleLogo
@@ -40,6 +42,8 @@
:is="presentation ? ViewerWrapper : 'div'"
:group="presentation"
class="h-full w-full object-cover"
@loading-change="onLoadingChange"
@progress-change="onProgressChange"
/>
</div>
@@ -50,7 +54,7 @@
/>
<PresentationControls
v-if="!hideUi"
:hide-ui="hideUi"
class="absolute left-4 md:left-1/2 md:-translate-x-1/2"
:class="[
isInfoSidebarOpen ? 'bottom-52 md:bottom-4' : 'bottom-4',
@@ -63,25 +67,55 @@
<script setup lang="ts">
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
import { useEventListener } from '@vueuse/core'
import { useEventListener, useBreakpoints } from '@vueuse/core'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import { useMixpanel } from '~~/lib/core/composables/mp'
const {
response: { presentation }
response: { presentation, workspace }
} = useInjectedPresentationState()
const mixpanel = useMixpanel()
const isMobile = useBreakpoints(TailwindBreakpoints).smaller('sm')
const isInfoSidebarOpen = ref(true)
const isLeftSidebarOpen = ref(true)
const hideUi = ref(false)
const isInfoSidebarOpen = ref(false)
const isLeftSidebarOpen = ref(false)
const hideUi = ref(true)
const isViewerLoading = ref(true)
const viewerProgress = ref(0)
const ViewerWrapper = resolveComponent('PresentationViewerWrapper')
const onLoadingChange = (loading: boolean) => {
isViewerLoading.value = loading
if (!loading) {
hideUi.value = false
isLeftSidebarOpen.value = !isMobile.value
isInfoSidebarOpen.value = !isMobile.value
}
}
const onProgressChange = (progress: number) => {
viewerProgress.value = progress
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'i' || event.key === 'I') {
hideUi.value = !hideUi.value
isLeftSidebarOpen.value = false
isInfoSidebarOpen.value = false
isLeftSidebarOpen.value = !hideUi.value
isInfoSidebarOpen.value = !hideUi.value
}
}
useEventListener('keydown', handleKeydown)
onMounted(() => {
mixpanel.track('Presentation Viewed', {
// eslint-disable-next-line camelcase
presentation_id: presentation.value?.id,
// eslint-disable-next-line camelcase
workspace_id: workspace.value?.id
})
})
</script>
@@ -47,6 +47,7 @@ graphql(`
const props = defineProps<{
slide: MaybeNullOrUndefined<PresentationSlideEditDialog_SavedViewFragment>
workspaceId: MaybeNullOrUndefined<string>
}>()
const open = defineModel<boolean>('open', { required: true })
@@ -61,10 +62,13 @@ const onSubmit = handleSubmit(async () => {
if (!props.slide?.id) return
await updateSlide({
id: props.slide.id,
projectId: props.slide.projectId,
name: name.value,
description: description.value
input: {
id: props.slide.id,
projectId: props.slide.projectId,
name: name.value,
description: description.value
},
workspaceId: props.workspaceId
})
open.value = false
@@ -1,5 +1,5 @@
<template>
<div class="p-4 pl-0 absolute">
<div class="p-4 absolute group">
<ul class="flex flex-col space-y-2">
<li v-for="slide in visibleSlides" :key="slide.id">
<div
@@ -9,11 +9,12 @@
</li>
</ul>
<!-- <div
class="hidden md:flex absolute top-[calc(50%+25px)] -translate-y-1/2 max-h-[75vh] overflow-y-auto w-56 simple-scrollbar bg-foundation border border-outline-3 rounded-xl p-3 shadow-md"
<div
v-if="showSlideList"
class="absolute top-[calc(50%+25px)] -translate-y-1/2 max-h-[75vh] overflow-y-auto w-56 simple-scrollbar bg-foundation border border-outline-3 rounded-xl p-3 shadow-md transition-all duration-300 ease-out opacity-0 invisible group-hover:opacity-100 group-hover:visible -translate-x-5 group-hover:translate-x-0"
>
<PresentationSlideList class="w-full" hide-title />
</div> -->
</div>
</div>
</template>
@@ -24,4 +25,8 @@ const {
ui: { slide: currentView },
response: { visibleSlides }
} = useInjectedPresentationState()
defineProps<{
showSlideList?: boolean
}>()
</script>
@@ -1,6 +1,7 @@
<template>
<div
class="flex items-center rounded-xl bg-foundation border border-outline-3 shadow-md overflow-hidden divide-x divide-outline-3"
:class="{ hidden: hideUi }"
>
<PresentationControlsButton
:icon="LucideChevronLeft"
@@ -22,6 +23,10 @@ import { useInjectedPresentationState } from '~/lib/presentations/composables/se
import { clamp } from 'lodash-es'
import { useEventListener } from '@vueuse/core'
defineProps<{
hideUi?: boolean
}>()
const {
ui: { slideIdx: currentVisibleIndex, slideCount },
viewer: { resetView }
@@ -38,15 +43,32 @@ const onNext = () => {
currentVisibleIndex.value = clamp(currentVisibleIndex.value + 1, 0, slideCount.value)
}
// TBD
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'ArrowLeft' && !disablePrevious.value) {
onPrevious()
}
if (event.key === 'ArrowRight' && !disableNext.value) {
onNext()
}
}
// Prevent viewer from moving when using arrow keys
useEventListener(
'keydown',
(event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (disablePrevious.value) return
onPrevious()
}
useEventListener('keydown', handleKeydown)
if (event.key === 'ArrowRight') {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (disableNext.value) return
onNext()
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
}
},
{ capture: true }
)
</script>
@@ -1,7 +1,9 @@
<template>
<button
class="size-8 flex items-center justify-center bg-foundation rounded-xl hover:bg-info-lighter hover:text-primary-focus"
:class="{ 'bg-info-lighter text-primary-focus': isActive }"
class="size-8 flex items-center justify-center bg-foundation rounded-xl hover:bg-info-lighter hover:text-primary-focus dark:hover:text-foreground-on-primary"
:class="{
'bg-info-lighter text-primary-focus dark:text-foreground-on-primary': isActive
}"
>
<slot />
</button>
@@ -1,5 +1,5 @@
<template>
<li :class="{ 'pb-4': hideTitle }">
<li :class="{ 'pb-1 last:pb-0': hideTitle }">
<button
class="bg-foundation-page rounded-xl overflow-hidden border border-outline-3 transition-all duration-200 hover:!border-outline-4"
:class="[isCurrentSlide ? '!border-outline-5' : '']"
@@ -1,6 +1,32 @@
<template>
<div class="presentation-viewer-setup h-full">
<ViewerCoreSetup viewer-host-classes="h-full" />
<ViewerCoreSetup
viewer-host-classes="h-full"
:disable-selection="disableSelection"
/>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
defineProps<{
disableSelection?: boolean
}>()
const emit = defineEmits<{
(e: 'loading-change', loading: boolean): void
(e: 'progress-change', progress: number): void
}>()
const {
ui: { loading, loadProgress }
} = useInjectedViewerState()
watch(loading, (newLoading) => {
emit('loading-change', newLoading)
})
watch(loadProgress, (newProgress) => {
emit('progress-change', newProgress)
})
</script>
@@ -1,6 +1,9 @@
<template>
<ViewerStateSetup :init-params="initParams">
<PresentationViewerSetup />
<PresentationViewerSetup
@loading-change="onLoadingChange"
@progress-change="onProgressChange"
/>
</ViewerStateSetup>
</template>
<script setup lang="ts">
@@ -66,6 +69,19 @@ const savedViewId = computed({
const loadOriginal = ref(false)
const emit = defineEmits<{
(e: 'loading-change', loading: boolean): void
(e: 'progress-change', progress: number): void
}>()
const onLoadingChange = (loading: boolean) => {
emit('loading-change', loading)
}
const onProgressChange = (progress: number) => {
emit('progress-change', progress)
}
const initParams = computed(
(): UseSetupViewerParams => ({
projectId,
@@ -2,20 +2,27 @@
<template>
<div
ref="resizableElement"
class="relative sm:absolute z-10 right-0 overflow-hidden w-screen bottom-0 sm:bottom-auto sm:top-[3.5rem] lg:top-[3rem] sm:right-2 lg:right-0 h-[40dvh] sm:h-[calc(100dvh-8rem)] lg:h-[calc(100dvh-3rem)] sm:max-w-[264px]"
class="relative sm:absolute z-10 right-0 overflow-hidden w-screen bottom-0 sm:bottom-auto sm:right-2 h-[40dvh] sm:max-w-[276px]"
:style="isLgOrLarger ? { maxWidth: width + 'px' } : {}"
:class="[open ? '' : 'pointer-events-none']"
:class="[
open ? '' : 'pointer-events-none',
isEmbedEnabled
? 'sm:top-2 sm:h-[calc(100dvh-8rem)]'
: 'sm:top-[3.5rem] lg:h-[calc(100dvh-3rem)] lg:top-[3rem] lg:right-0 sm:h-[calc(100dvh-8rem)]'
]"
>
<div class="flex h-full" :class="open ? '' : 'sm:translate-x-[100%]'">
<!-- Resize Handle -->
<div
v-if="!isEmbedEnabled"
ref="resizeHandle"
class="absolute h-full max-h-[calc(100dvh-3rem)] w-4 transition border-l sm:rounded-lg lg:rounded-none hover:border-l-[2px] border-outline-2 hover:border-primary hidden lg:flex items-center cursor-ew-resize z-30"
@mousedown="startResizing"
/>
<div
class="flex flex-col w-full h-full relative z-20 overflow-hidden sm:rounded-lg lg:rounded-none border-l border-t sm:border lg:border-0 lg:border-l border-outline-2 bg-foundation"
class="flex flex-col w-full h-full relative z-20 overflow-hidden sm:rounded-lg border-l border-t sm:border border-outline-2 bg-foundation"
:class="!isEmbedEnabled ? 'lg:rounded-none lg:border-0 lg:border-l' : ''"
>
<div
class="h-10 pl-4 pr-2 flex items-center justify-between border-b border-outline-2"
@@ -39,6 +46,7 @@
import { ref, onMounted } from 'vue'
import { useEventListener, useBreakpoints } from '@vueuse/core'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
defineProps<{
open: boolean
@@ -52,12 +60,13 @@ const emit = defineEmits<{
const resizableElement = ref(null)
const resizeHandle = ref(null)
const isResizing = ref(false)
const width = ref(280)
const width = ref(276)
let startWidth = 0
let startX = 0
const breakpoints = useBreakpoints(TailwindBreakpoints)
const isLgOrLarger = breakpoints.greaterOrEqual('lg')
const { isEnabled: isEmbedEnabled } = useEmbed()
const startResizing = (event: MouseEvent) => {
event.preventDefault()
@@ -1,11 +1,11 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<aside
class="absolute left-2 lg:left-0 z-50 flex rounded-lg border border-outline-2 bg-foundation px-1 overflow-visible lg:h-full focus-visible:outline-none"
class="absolute left-2 z-50 flex rounded-lg border border-outline-2 bg-foundation px-1 overflow-visible focus-visible:outline-none"
:class="[
isEmbedEnabled
? 'top-[0.5rem]'
: 'top-[3.5rem] lg:top-[3rem] lg:rounded-none lg:px-2 lg:max-h-[calc(100dvh-3rem)] lg:border-l-0 lg:border-t-0 lg:border-b-0',
: 'top-[3.5rem] lg:top-[3rem] lg:rounded-none lg:px-2 lg:max-h-[calc(100dvh-3rem)] lg:border-l-0 lg:border-t-0 lg:border-b-0 lg:h-full lg:left-0',
hasActivePanel && 'h-full max-h-[calc(100dvh-8rem)] rounded-r-none'
]"
>
@@ -121,7 +121,7 @@
<!-- Resize handle -->
<div
v-if="activePanel !== 'none'"
v-if="activePanel !== 'none' && !isEmbedEnabled"
ref="resizeHandle"
class="absolute h-full max-h-[calc(100dvh-3rem)] w-4 transition border-l hover:border-l-[2px] border-outline-2 hover:border-primary hidden lg:flex items-center cursor-ew-resize z-30"
:style="`left:${width + 52}px;`"
@@ -289,7 +289,7 @@ const throttledHandleMouseMove = useThrottleFn((event: MouseEvent) => {
)
panelExtensionWidth.value = newWidth
}
}, 150)
}, 50)
if (import.meta.client) {
useResizeObserver(scrollableControlsContainer, (entries) => {
@@ -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)
}
@@ -10,9 +10,9 @@
@mouseleave="unhighlightObject"
@focusin="highlightObject"
@focusout="unhighlightObject"
@click="selectObject"
@click="handleClick"
@dblclick="zoomToModel"
@keydown.enter="selectObject"
@keydown.enter="handleClick"
>
<ViewerExpansionTriangle
class="h-8"
@@ -104,7 +104,8 @@ import type { Get } from 'type-fest'
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
import {
useHighlightedObjectsUtilities,
useCameraUtilities
useCameraUtilities,
useSelectionUtilities
} from '~~/lib/viewer/composables/ui'
import { useFilterUtilities } from '~/lib/viewer/composables/filtering/filtering'
import {
@@ -139,6 +140,8 @@ const { hideObjects, showObjects, isolateObjects, unIsolateObjects } =
const { zoom } = useCameraUtilities()
const { items } = useInjectedViewerRequestedResources()
const { resourceItems } = useInjectedViewerLoadedResources()
const { addToSelectionFromObjectIds } = useSelectionUtilities()
const {
viewer: {
metadata: { filteringState }
@@ -306,10 +309,11 @@ const unhighlightObject = () => {
if (refObject && typeof refObject === 'string') unhighlightObjects([refObject])
}
const selectObject = () => {
// Only expand if not already expanded
const handleClick = () => {
if (!props.isExpanded) {
emit('toggle-expansion')
} else {
addToSelectionFromObjectIds(modelObjectIds.value)
}
}
@@ -3,8 +3,8 @@
<button
class="w-full border-b border-outline-3 p-3 cursor-pointer group text-left"
:class="getObjectBackgroundClass()"
@click="handleObjectClick"
@keydown.enter="handleObjectClick"
@click="handleClick"
@keydown.enter="handleClick"
@mouseenter="handleObjectMouseEnter"
@mouseleave="handleObjectMouseLeave"
>
@@ -33,19 +33,14 @@ const props = defineProps<{
const {
objects: selectedObjects,
addToSelection,
addToSelectionFromObjectIds,
clearSelection
} = useSelectionUtilities()
const { highlightObjects, unhighlightObjects } = useHighlightedObjectsUtilities()
const handleObjectClick = () => {
const objectData = {
id: props.objectId,
speckle_type: 'Base' // eslint-disable-line camelcase
}
const handleClick = () => {
clearSelection()
addToSelection(objectData)
addToSelectionFromObjectIds([props.objectId])
}
const handleObjectMouseEnter = () => {
@@ -0,0 +1,111 @@
<!-- eslint-disable vuejs-accessibility/mouse-events-have-key-events -->
<template>
<button
class="w-full h-16 pr-2 py-2 cursor-pointer group text-left bg-foundation hover:bg-highlight-1 border-b border-outline-3"
:class="getObjectBackgroundClass()"
@click="handleObjectClick"
@keydown.enter="handleObjectClick"
@mouseenter="handleObjectMouseEnter"
@mouseleave="handleObjectMouseLeave"
>
<div class="flex items-center gap-1 h-full">
<ViewerExpansionTriangle
class="h-8"
:is-expanded="isExpanded"
@click="$emit('toggleExpansion', objectId)"
/>
<div class="flex items-center gap-2 min-w-0 flex-1">
<CubeIcon class="w-4 h-4 shrink-0" />
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<div class="text-body-2xs font-medium text-foreground truncate">
Detached Object
</div>
<div class="text-body-3xs text-foreground-2 truncate">
{{ objectId }}
</div>
</div>
</div>
</div>
</button>
</template>
<script setup lang="ts">
import { CubeIcon } from '@heroicons/vue/24/outline'
import {
useSelectionUtilities,
useHighlightedObjectsUtilities
} from '~~/lib/viewer/composables/ui'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import type { ExplorerNode } from '~/lib/viewer/helpers/sceneExplorer'
const props = defineProps<{
objectId: string
isExpanded?: boolean
}>()
const emit = defineEmits<{
toggleExpansion: [objectId: string]
}>()
const {
viewer: {
metadata: { worldTree }
}
} = useInjectedViewerState()
const {
objects: selectedObjects,
addToSelectionFromObjectIds,
clearSelection
} = useSelectionUtilities()
const { highlightObjects, unhighlightObjects } = useHighlightedObjectsUtilities()
const objectNode = computed(() => {
if (!worldTree.value) return null
const rootNodes = worldTree.value._root.children
return rootNodes?.find((node: ExplorerNode) => {
const nodeObjectId = ((node.model as Record<string, unknown>).id as string)
.split('/')
.reverse()[0] as string
return nodeObjectId === props.objectId
})
})
const hasHierarchy = computed(() => {
return objectNode.value?.model?.children && objectNode.value.model.children.length > 0
})
const handleObjectClick = () => {
if (hasHierarchy.value) {
if (!props.isExpanded) {
emit('toggleExpansion', props.objectId)
} else {
clearSelection()
addToSelectionFromObjectIds([props.objectId])
}
} else {
clearSelection()
addToSelectionFromObjectIds([props.objectId])
}
}
const handleObjectMouseEnter = () => {
if (props.objectId && typeof props.objectId === 'string') {
highlightObjects([props.objectId])
}
}
const handleObjectMouseLeave = () => {
if (props.objectId && typeof props.objectId === 'string') {
unhighlightObjects([props.objectId])
}
}
const getObjectBackgroundClass = (): string => {
const isSelected = selectedObjects.value.find((o) => o.id === props.objectId)
if (isSelected) return 'bg-highlight-3'
return 'bg-foundation hover:bg-highlight-1'
}
</script>
@@ -27,18 +27,10 @@
<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
v-if="!isDetachedObjectSticky"
:model="stickyHeader!.model"
:version-id="stickyHeader!.versionId"
:is-expanded="expandedModels.has(stickyHeader!.model.id)"
@@ -46,6 +38,12 @@
@show-versions="handleShowVersions"
@show-diff="handleShowDiff"
/>
<ViewerModelsDetachedObjectHeader
v-else
:object-id="stickyHeader!.versionId"
:is-expanded="expandedModels.has(stickyHeader!.model.id)"
@toggle-expansion="toggleModelExpansion"
/>
</div>
<div
@@ -54,16 +52,17 @@
v-bind="containerProps"
@scroll="handleScroll"
>
<div v-bind="wrapperProps">
<div v-bind="wrapperProps" class="model-list">
<div
v-for="{ data: item } in virtualList"
:key="item.id"
:data-item-id="item.id"
:data-item-type="item.type"
class="group first:hidden"
>
<!-- Model Header -->
<template v-if="item.type === 'model-header'">
<div class="bg-foundation h-16">
<div class="bg-foundation h-16 model-header">
<ViewerModelsCard
:model="getModelFromItem(item)"
:version-id="getVersionIdFromItem(item)"
@@ -75,6 +74,17 @@
</div>
</template>
<!-- Detached Object Header -->
<template v-else-if="item.type === 'detached-object-header'">
<div class="bg-foundation h-16 model-header">
<ViewerModelsDetachedObjectHeader
:object-id="getObjectIdFromItem(item)"
:is-expanded="expandedModels.has(item.modelId)"
@toggle-expansion="toggleModelExpansion(item.modelId)"
/>
</div>
</template>
<!-- Tree Item -->
<template v-else-if="item.type === 'tree-item'">
<ViewerModelsVirtualTreeItem
@@ -178,6 +188,7 @@ const unifiedVirtualItems = computed(() => {
selectedObjects.value,
worldTree.value || null,
stateResourceItems.value as { objectId: string; modelId?: string }[],
objects.value,
getRootNodesForModel,
flattenModelTree
)
@@ -190,7 +201,9 @@ const {
} = useVirtualList(unifiedVirtualItems, {
itemHeight: (index) => {
const item = unifiedVirtualItems.value[index]
return item?.type === 'model-header' ? 64 : 40
return item?.type === 'model-header' || item?.type === 'detached-object-header'
? 64
: 40
},
overscan: 20
})
@@ -207,7 +220,8 @@ const modelHeaderPositions = computed(() => {
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
const itemHeight =
item.type === 'model-header' || item.type === 'detached-object-header' ? 64 : 40
if (item.type === 'model-header') {
const data = item.data as { model: ModelItem; versionId: string }
@@ -217,6 +231,20 @@ const modelHeaderPositions = computed(() => {
versionId: data.versionId,
position: cumulativeHeight
})
} else if (item.type === 'detached-object-header') {
const data = item.data as { objectId: string }
// Create a detached object header item in the virtual list
const detachedObjectHeader = {
id: data.objectId,
name: 'Detached Object',
displayName: 'Detached Object'
} as unknown as ModelItem
headers.push({
index: i,
model: detachedObjectHeader,
versionId: data.objectId,
position: cumulativeHeight
})
}
cumulativeHeight += itemHeight
}
@@ -227,6 +255,11 @@ const hasDiffActive = computed(() => {
return !!(diffState.oldVersion.value && diffState.newVersion.value)
})
const isDetachedObjectSticky = computed(() => {
if (!stickyHeader.value) return false
return objects.value.some((obj) => obj.objectId === stickyHeader.value?.model.id)
})
const handleShowVersions = (modelId: string) => {
expandedModelId.value = modelId
subView.value = ModelsSubView.Versions
@@ -314,6 +347,13 @@ const getVersionIdFromItem = (item: UnifiedVirtualItem): string => {
return ''
}
const getObjectIdFromItem = (item: UnifiedVirtualItem): string => {
if (item.type === 'detached-object-header') {
return (item.data as { objectId: string }).objectId
}
return ''
}
const scrollToSelectedItem = (objectId: string) => {
nextTick(() => {
const itemIndex = unifiedVirtualItems.value.findIndex(
@@ -425,14 +465,27 @@ 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
const firstHeader = items.find(
(item) => item.type === 'model-header' || item.type === 'detached-object-header'
)
if (firstHeader) {
if (firstHeader.type === 'model-header') {
const data = firstHeader.data as { model: ModelItem; versionId: string }
stickyHeader.value = {
model: data.model,
versionId: data.versionId
}
} else if (firstHeader.type === 'detached-object-header') {
const data = firstHeader.data as { objectId: string }
const detachedObjectHeader = {
id: data.objectId,
name: 'Detached Object',
displayName: 'Detached Object'
} as unknown as ModelItem
stickyHeader.value = {
model: detachedObjectHeader,
versionId: data.objectId
}
}
}
} else {
@@ -442,3 +495,17 @@ watch(
{ immediate: true }
)
</script>
<style scoped>
/* Add border-top to model/detached object headers that follow tree items using css */
.model-list
.group[data-item-type='tree-item']
+ .group[data-item-type='model-header']
.model-header,
.model-list
.group[data-item-type='tree-item']
+ .group[data-item-type='detached-object-header']
.model-header {
@apply border-t border-outline-3;
}
</style>
@@ -1,7 +1,7 @@
<template>
<ViewerCommentsPortalOrDiv class="relative" to="bottomPanel">
<ViewerControlsRight
v-if="isGreaterThanSm"
v-if="isGreaterThanSm && showControls"
:sidebar-open="sidebarOpen && shouldRenderSidebar"
:sidebar-width="sidebarWidth"
/>
@@ -90,6 +90,7 @@ import { modelRoute } from '~/lib/common/helpers/route'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
import { Ellipsis } from 'lucide-vue-next'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
enum ActionTypes {
OpenInNewTab = 'open-in-new-tab'
@@ -112,6 +113,7 @@ const breakpoints = useBreakpoints(TailwindBreakpoints)
const isGreaterThanSm = breakpoints.greater('sm')
const menuId = useId()
const mp = useMixpanel()
const { showControls } = useEmbed()
const itemCount = ref(20)
const sidebarOpen = ref(false)
@@ -46,11 +46,10 @@ type Documents = {
"\n fragment DashboardsCard_Dashboard on Dashboard {\n id\n name\n createdAt\n workspace {\n id\n }\n createdBy {\n id\n name\n avatar\n }\n permissions {\n canDelete {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.DashboardsCard_DashboardFragmentDoc,
"\n fragment DashboardsEditDialog_Dashboard on Dashboard {\n id\n name\n workspace {\n id\n }\n }\n": typeof types.DashboardsEditDialog_DashboardFragmentDoc,
"\n query DashboardsListCanCreateDashboards($slug: String!) {\n workspaceBySlug(slug: $slug) {\n permissions {\n canCreateDashboards {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": typeof types.DashboardsListCanCreateDashboardsDocument,
"\n query DashboardsSharDialogPermissions($id: String!) {\n dashboard(id: $id) {\n id\n shareLink {\n id\n content\n revoked\n }\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": typeof types.DashboardsSharDialogPermissionsDocument,
"\n query DashboardsSharDialogPermissions($id: String!) {\n dashboard(id: $id) {\n id\n shareLink {\n id\n content\n revoked\n }\n }\n }\n": typeof types.DashboardsSharDialogPermissionsDocument,
"\n mutation DashboardsShareToken($dashboardId: String!) {\n dashboardMutations {\n share(dashboardId: $dashboardId) {\n id\n revoked\n content\n }\n }\n }\n": typeof types.DashboardsShareTokenDocument,
"\n mutation DashboardsShareEnableToken($input: DashboardShareInput!) {\n dashboardMutations {\n enableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n": typeof types.DashboardsShareEnableTokenDocument,
"\n mutation DashboardsShareDisableToken($input: DashboardShareInput!) {\n dashboardMutations {\n disableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n": typeof types.DashboardsShareDisableTokenDocument,
"\n query DashboardsSharePermissions($id: String!) {\n dashboard(id: $id) {\n id\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": typeof types.DashboardsSharePermissionsDocument,
"\n fragment FormSelectModels_Model on Model {\n id\n name\n }\n": typeof types.FormSelectModels_ModelFragmentDoc,
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": typeof types.FormSelectProjects_ProjectFragmentDoc,
"\n fragment FormSelectSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n isUngroupedViewsGroup\n }\n": typeof types.FormSelectSavedViewGroup_SavedViewGroupFragmentDoc,
@@ -70,8 +69,8 @@ type Documents = {
"\n query InviteDialogProjectRowProjectCollaborators(\n $projectId: String!\n $filter: InvitableCollaboratorsFilter\n ) {\n project(id: $projectId) {\n id\n invitableCollaborators(filter: $filter) {\n items {\n ...InviteProjectItem_WorkspaceCollaborator\n }\n }\n }\n }\n": typeof types.InviteDialogProjectRowProjectCollaboratorsDocument,
"\n fragment InviteDialogSharedSelectUsers_Workspace on Workspace {\n id\n slug\n defaultSeatType\n }\n": typeof types.InviteDialogSharedSelectUsers_WorkspaceFragmentDoc,
"\n fragment PresentationHeader_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": typeof types.PresentationHeader_SavedViewGroupFragmentDoc,
"\n fragment PresentationInfoSidebar_SavedViewGroup on SavedViewGroup {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n ...PresentationSlideEditDialog_SavedView\n name\n description\n }\n": typeof types.PresentationInfoSidebar_SavedViewGroupFragmentDoc,
"\n fragment PresentationLeftSidebar_Workspace on Workspace {\n id\n name\n logo\n }\n": typeof types.PresentationLeftSidebar_WorkspaceFragmentDoc,
"\n fragment PresentationInfoSidebar_SavedViewGroup on SavedViewGroup {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n ...PresentationSlideEditDialog_SavedView\n name\n description\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.PresentationInfoSidebar_SavedViewGroupFragmentDoc,
"\n fragment PresentationLeftSidebar_Workspace on Workspace {\n id\n name\n logo\n slug\n }\n": typeof types.PresentationLeftSidebar_WorkspaceFragmentDoc,
"\n fragment PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n screenshot\n }\n": typeof types.PresentationSlideEditDialog_SavedViewFragmentDoc,
"\n fragment PresentationSlideListSlide_SavedView on SavedView {\n id\n name\n screenshot\n }\n": typeof types.PresentationSlideListSlide_SavedViewFragmentDoc,
"\n fragment PresentationSlideList_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n items {\n id\n ...PresentationSlideListSlide_SavedView\n }\n }\n }\n": typeof types.PresentationSlideList_SavedViewGroupFragmentDoc,
@@ -544,7 +543,7 @@ type Documents = {
"\n fragment SettingsWorkspacesRegions_Workspace on Workspace {\n id\n role\n defaultRegion {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n hasAccessToMultiRegion: hasAccessToFeature(\n featureName: workspaceDataRegionSpecificity\n )\n hasProjects: projects(limit: 0) {\n totalCount\n }\n }\n": typeof types.SettingsWorkspacesRegions_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesRegions_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": typeof types.SettingsWorkspacesRegions_ServerInfoFragmentDoc,
"\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n ...SettingsWorkspacesSecurityDefaultSeat_Workspace\n ...SettingsWorkspacesSecurityDomainManagement_Workspace\n ...SettingsWorkspacesSecurityDiscoverability_Workspace\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n ...SettingsWorkspacesSecurityDomainProtection_Workspace\n ...SettingsWorkspacesSecurityWorkspaceCreation_Workspace\n id\n slug\n }\n": typeof types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc,
"\n fragment WorkspaceDashboards_Dashboard on Dashboard {\n ...DashboardsEditDialog_Dashboard\n id\n name\n createdBy {\n id\n name\n avatar\n }\n createdAt\n updatedAt\n workspace {\n id\n name\n slug\n logo\n }\n }\n": typeof types.WorkspaceDashboards_DashboardFragmentDoc,
"\n fragment WorkspaceDashboards_Dashboard on Dashboard {\n ...DashboardsEditDialog_Dashboard\n id\n name\n createdBy {\n id\n name\n avatar\n }\n createdAt\n updatedAt\n workspace {\n id\n name\n slug\n logo\n }\n permissions {\n canEdit {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.WorkspaceDashboards_DashboardFragmentDoc,
"\n fragment WorkspacePage_Workspace on Workspace {\n ...WorkspaceDashboard_Workspace\n ...WorkspaceSidebar_Workspace\n }\n": typeof types.WorkspacePage_WorkspaceFragmentDoc,
};
const documents: Documents = {
@@ -580,11 +579,10 @@ const documents: Documents = {
"\n fragment DashboardsCard_Dashboard on Dashboard {\n id\n name\n createdAt\n workspace {\n id\n }\n createdBy {\n id\n name\n avatar\n }\n permissions {\n canDelete {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.DashboardsCard_DashboardFragmentDoc,
"\n fragment DashboardsEditDialog_Dashboard on Dashboard {\n id\n name\n workspace {\n id\n }\n }\n": types.DashboardsEditDialog_DashboardFragmentDoc,
"\n query DashboardsListCanCreateDashboards($slug: String!) {\n workspaceBySlug(slug: $slug) {\n permissions {\n canCreateDashboards {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": types.DashboardsListCanCreateDashboardsDocument,
"\n query DashboardsSharDialogPermissions($id: String!) {\n dashboard(id: $id) {\n id\n shareLink {\n id\n content\n revoked\n }\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": types.DashboardsSharDialogPermissionsDocument,
"\n query DashboardsSharDialogPermissions($id: String!) {\n dashboard(id: $id) {\n id\n shareLink {\n id\n content\n revoked\n }\n }\n }\n": types.DashboardsSharDialogPermissionsDocument,
"\n mutation DashboardsShareToken($dashboardId: String!) {\n dashboardMutations {\n share(dashboardId: $dashboardId) {\n id\n revoked\n content\n }\n }\n }\n": types.DashboardsShareTokenDocument,
"\n mutation DashboardsShareEnableToken($input: DashboardShareInput!) {\n dashboardMutations {\n enableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n": types.DashboardsShareEnableTokenDocument,
"\n mutation DashboardsShareDisableToken($input: DashboardShareInput!) {\n dashboardMutations {\n disableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n": types.DashboardsShareDisableTokenDocument,
"\n query DashboardsSharePermissions($id: String!) {\n dashboard(id: $id) {\n id\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n": types.DashboardsSharePermissionsDocument,
"\n fragment FormSelectModels_Model on Model {\n id\n name\n }\n": types.FormSelectModels_ModelFragmentDoc,
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": types.FormSelectProjects_ProjectFragmentDoc,
"\n fragment FormSelectSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n isUngroupedViewsGroup\n }\n": types.FormSelectSavedViewGroup_SavedViewGroupFragmentDoc,
@@ -604,8 +602,8 @@ const documents: Documents = {
"\n query InviteDialogProjectRowProjectCollaborators(\n $projectId: String!\n $filter: InvitableCollaboratorsFilter\n ) {\n project(id: $projectId) {\n id\n invitableCollaborators(filter: $filter) {\n items {\n ...InviteProjectItem_WorkspaceCollaborator\n }\n }\n }\n }\n": types.InviteDialogProjectRowProjectCollaboratorsDocument,
"\n fragment InviteDialogSharedSelectUsers_Workspace on Workspace {\n id\n slug\n defaultSeatType\n }\n": types.InviteDialogSharedSelectUsers_WorkspaceFragmentDoc,
"\n fragment PresentationHeader_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": types.PresentationHeader_SavedViewGroupFragmentDoc,
"\n fragment PresentationInfoSidebar_SavedViewGroup on SavedViewGroup {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n ...PresentationSlideEditDialog_SavedView\n name\n description\n }\n": types.PresentationInfoSidebar_SavedViewGroupFragmentDoc,
"\n fragment PresentationLeftSidebar_Workspace on Workspace {\n id\n name\n logo\n }\n": types.PresentationLeftSidebar_WorkspaceFragmentDoc,
"\n fragment PresentationInfoSidebar_SavedViewGroup on SavedViewGroup {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n ...PresentationSlideEditDialog_SavedView\n name\n description\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.PresentationInfoSidebar_SavedViewGroupFragmentDoc,
"\n fragment PresentationLeftSidebar_Workspace on Workspace {\n id\n name\n logo\n slug\n }\n": types.PresentationLeftSidebar_WorkspaceFragmentDoc,
"\n fragment PresentationSlideEditDialog_SavedView on SavedView {\n id\n projectId\n name\n description\n screenshot\n }\n": types.PresentationSlideEditDialog_SavedViewFragmentDoc,
"\n fragment PresentationSlideListSlide_SavedView on SavedView {\n id\n name\n screenshot\n }\n": types.PresentationSlideListSlide_SavedViewFragmentDoc,
"\n fragment PresentationSlideList_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n items {\n id\n ...PresentationSlideListSlide_SavedView\n }\n }\n }\n": types.PresentationSlideList_SavedViewGroupFragmentDoc,
@@ -1078,7 +1076,7 @@ const documents: Documents = {
"\n fragment SettingsWorkspacesRegions_Workspace on Workspace {\n id\n role\n defaultRegion {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n hasAccessToMultiRegion: hasAccessToFeature(\n featureName: workspaceDataRegionSpecificity\n )\n hasProjects: projects(limit: 0) {\n totalCount\n }\n }\n": types.SettingsWorkspacesRegions_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesRegions_ServerInfo on ServerInfo {\n multiRegion {\n regions {\n id\n ...SettingsWorkspacesRegionsSelect_ServerRegionItem\n }\n }\n }\n": types.SettingsWorkspacesRegions_ServerInfoFragmentDoc,
"\n fragment SettingsWorkspacesSecurity_Workspace on Workspace {\n ...SettingsWorkspacesSecurityDefaultSeat_Workspace\n ...SettingsWorkspacesSecurityDomainManagement_Workspace\n ...SettingsWorkspacesSecurityDiscoverability_Workspace\n ...SettingsWorkspacesSecuritySsoWrapper_Workspace\n ...SettingsWorkspacesSecurityDomainProtection_Workspace\n ...SettingsWorkspacesSecurityWorkspaceCreation_Workspace\n id\n slug\n }\n": types.SettingsWorkspacesSecurity_WorkspaceFragmentDoc,
"\n fragment WorkspaceDashboards_Dashboard on Dashboard {\n ...DashboardsEditDialog_Dashboard\n id\n name\n createdBy {\n id\n name\n avatar\n }\n createdAt\n updatedAt\n workspace {\n id\n name\n slug\n logo\n }\n }\n": types.WorkspaceDashboards_DashboardFragmentDoc,
"\n fragment WorkspaceDashboards_Dashboard on Dashboard {\n ...DashboardsEditDialog_Dashboard\n id\n name\n createdBy {\n id\n name\n avatar\n }\n createdAt\n updatedAt\n workspace {\n id\n name\n slug\n logo\n }\n permissions {\n canEdit {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.WorkspaceDashboards_DashboardFragmentDoc,
"\n fragment WorkspacePage_Workspace on Workspace {\n ...WorkspaceDashboard_Workspace\n ...WorkspaceSidebar_Workspace\n }\n": types.WorkspacePage_WorkspaceFragmentDoc,
};
@@ -1227,7 +1225,7 @@ export function graphql(source: "\n query DashboardsListCanCreateDashboards($sl
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query DashboardsSharDialogPermissions($id: String!) {\n dashboard(id: $id) {\n id\n shareLink {\n id\n content\n revoked\n }\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"): (typeof documents)["\n query DashboardsSharDialogPermissions($id: String!) {\n dashboard(id: $id) {\n id\n shareLink {\n id\n content\n revoked\n }\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"];
export function graphql(source: "\n query DashboardsSharDialogPermissions($id: String!) {\n dashboard(id: $id) {\n id\n shareLink {\n id\n content\n revoked\n }\n }\n }\n"): (typeof documents)["\n query DashboardsSharDialogPermissions($id: String!) {\n dashboard(id: $id) {\n id\n shareLink {\n id\n content\n revoked\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1240,10 +1238,6 @@ export function graphql(source: "\n mutation DashboardsShareEnableToken($input:
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation DashboardsShareDisableToken($input: DashboardShareInput!) {\n dashboardMutations {\n disableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n"): (typeof documents)["\n mutation DashboardsShareDisableToken($input: DashboardShareInput!) {\n dashboardMutations {\n disableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query DashboardsSharePermissions($id: String!) {\n dashboard(id: $id) {\n id\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"): (typeof documents)["\n query DashboardsSharePermissions($id: String!) {\n dashboard(id: $id) {\n id\n permissions {\n canCreateToken {\n ...FullPermissionCheckResult\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1323,11 +1317,11 @@ export function graphql(source: "\n fragment PresentationHeader_SavedViewGroup
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment PresentationInfoSidebar_SavedViewGroup on SavedViewGroup {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n ...PresentationSlideEditDialog_SavedView\n name\n description\n }\n"): (typeof documents)["\n fragment PresentationInfoSidebar_SavedViewGroup on SavedViewGroup {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n ...PresentationSlideEditDialog_SavedView\n name\n description\n }\n"];
export function graphql(source: "\n fragment PresentationInfoSidebar_SavedViewGroup on SavedViewGroup {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n ...PresentationSlideEditDialog_SavedView\n name\n description\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment PresentationInfoSidebar_SavedViewGroup on SavedViewGroup {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n\n fragment PresentationInfoSidebar_SavedView on SavedView {\n id\n ...PresentationSlideEditDialog_SavedView\n name\n description\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment PresentationLeftSidebar_Workspace on Workspace {\n id\n name\n logo\n }\n"): (typeof documents)["\n fragment PresentationLeftSidebar_Workspace on Workspace {\n id\n name\n logo\n }\n"];
export function graphql(source: "\n fragment PresentationLeftSidebar_Workspace on Workspace {\n id\n name\n logo\n slug\n }\n"): (typeof documents)["\n fragment PresentationLeftSidebar_Workspace on Workspace {\n id\n name\n logo\n slug\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -3219,7 +3213,7 @@ export function graphql(source: "\n fragment SettingsWorkspacesSecurity_Workspa
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment WorkspaceDashboards_Dashboard on Dashboard {\n ...DashboardsEditDialog_Dashboard\n id\n name\n createdBy {\n id\n name\n avatar\n }\n createdAt\n updatedAt\n workspace {\n id\n name\n slug\n logo\n }\n }\n"): (typeof documents)["\n fragment WorkspaceDashboards_Dashboard on Dashboard {\n ...DashboardsEditDialog_Dashboard\n id\n name\n createdBy {\n id\n name\n avatar\n }\n createdAt\n updatedAt\n workspace {\n id\n name\n slug\n logo\n }\n }\n"];
export function graphql(source: "\n fragment WorkspaceDashboards_Dashboard on Dashboard {\n ...DashboardsEditDialog_Dashboard\n id\n name\n createdBy {\n id\n name\n avatar\n }\n createdAt\n updatedAt\n workspace {\n id\n name\n slug\n logo\n }\n permissions {\n canEdit {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment WorkspaceDashboards_Dashboard on Dashboard {\n ...DashboardsEditDialog_Dashboard\n id\n name\n createdBy {\n id\n name\n avatar\n }\n createdAt\n updatedAt\n workspace {\n id\n name\n slug\n logo\n }\n permissions {\n canEdit {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -6,13 +6,20 @@ import {
} from '~/lib/common/helpers/graphql'
import type { UpdateSavedViewInput } from '~/lib/common/generated/gql/graphql'
import { updatePresentationSlideMutation } from '~/lib/presentations/graphql/mutations'
import { useMixpanel } from '~~/lib/core/composables/mp'
import type { MaybeNullOrUndefined } from '@speckle/shared'
export const useUpdatePresentationSlide = () => {
const { mutate, loading } = useMutation(updatePresentationSlideMutation)
const { triggerNotification } = useGlobalToast()
const mixpanel = useMixpanel()
return {
mutate: async (input: UpdateSavedViewInput) => {
mutate: async (params: {
input: UpdateSavedViewInput
workspaceId: MaybeNullOrUndefined<string>
}) => {
const { input, workspaceId } = params
const result = await mutate({ input }).catch(convertThrowIntoFetchResult)
if (result?.data?.projectMutations.savedViewMutations.updateView) {
@@ -20,6 +27,11 @@ export const useUpdatePresentationSlide = () => {
type: ToastNotificationType.Success,
title: 'Slide updated'
})
mixpanel.track('Presentation Slide Updated', {
// eslint-disable-next-line camelcase
workspace_id: workspaceId
})
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
@@ -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 { HighlightExtension } 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 = <T>(filterFn: () => T): T => {
const selectionExtension = instance.getExtension(SelectionExtension)
const highlightExtension = instance.getExtension(HighlightExtension)
// 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 }
)
@@ -9,12 +9,13 @@ import {
} from '@speckle/viewer'
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
import { useOnViewerLoadComplete } from '~/lib/viewer/composables/viewer'
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)
@@ -44,23 +45,22 @@ class HighlightExtension extends SelectionExtension {
export const useHighlightingPostSetup = () => {
const {
ui: { highlightedObjectIds },
viewer: { instance }
viewer: { instance },
pageType
} = useInjectedViewerState()
const highlightExtension = ref<HighlightExtension | null>(null)
if (pageType.value === ViewerRenderPageType.Presentation) return
// Create the highlighting extension once during setup
instance.createExtension(HighlightExtension)
// Get the highlighting extension instance
const getHighlightExtension = () => {
if (!highlightExtension.value) {
highlightExtension.value = instance.createExtension(HighlightExtension)
}
return highlightExtension.value
}
const getHighlightExtensionInstance = () => instance.getExtension(HighlightExtension)
useOnViewerLoadComplete(
({ isInitial }) => {
if (!isInitial) return
getHighlightExtension()
getHighlightExtensionInstance()
},
{ initialOnly: true }
)
@@ -69,7 +69,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
@@ -79,8 +79,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)
@@ -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,
HighlightExtension
} from '~/lib/viewer/composables/setup/highlighting'
function useViewerLoadCompleteEventHandler() {
const state = useInjectedViewerState()
@@ -566,17 +569,33 @@ 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 (
currentViewerSelection.length === newIds.length &&
difference(currentViewerSelection, newIds).length === 0
) {
return
}
state.ui.highlightedObjectIds.value = []
const highlightExtension = instance.getExtension(HighlightExtension)
if (highlightExtension) {
highlightExtension.clearSelection()
}
selectionExtension.clearSelection()
if (newVal.length > 0) {
selectionExtension.selectObjects(newIds)
}
},
{ immediate: true, flush: 'sync' }
{
immediate: true,
flush: 'sync'
}
)
}
@@ -5,6 +5,7 @@ import { useMixpanel } from '~~/lib/core/composables/mp'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { useCameraUtilities, useSelectionUtilities } from '~~/lib/viewer/composables/ui'
import { useSelectionEvents } from '~~/lib/viewer/composables/viewer'
import { ViewerRenderPageType } from '~/lib/viewer/helpers/state'
function useCollectSelection() {
const {
@@ -46,8 +47,11 @@ function useSelectOrZoomOnSelection() {
if (args.hits.length === 0) return trackAndClearSelection()
if (!args.multiple) clearSelection() // note we're not tracking selectino clearing here
// Skip if selection disabled
if (!state.viewer.instance.getExtension(SelectionExtension).enabled) {
// Skip if selection disabled or in presentation mode
if (
!state.viewer.instance.getExtension(SelectionExtension).enabled ||
state.pageType.value === ViewerRenderPageType.Presentation
) {
return
}
@@ -36,10 +36,10 @@ const isReferencedIdArray = (value: unknown): value is { referencedId: string }[
}
export type UnifiedVirtualItem = {
type: 'model-header' | 'tree-item'
type: 'model-header' | 'tree-item' | 'detached-object-header'
id: string
modelId: string
data: ExplorerNode | ModelWithVersion
data: ExplorerNode | ModelWithVersion | { objectId: string }
indent?: number
hasChildren?: boolean
isExpanded?: boolean
@@ -51,7 +51,7 @@ export type UnifiedVirtualItem = {
}
function createTreeStateManager() {
const flattenedTreeCache = reactive(new Map<string, UnifiedVirtualItem[]>())
const flattenedTreeCache = new Map<string, UnifiedVirtualItem[]>()
const lastCacheKey = ref('')
const isInitialized = ref(false)
@@ -75,7 +75,8 @@ function createTreeStateManager() {
modelsAndVersionIds: { model: ModelItem; versionId: string }[],
expandedModels: Set<string>,
expandedNodes: Set<string>,
selectedObjects: { id: string }[]
selectedObjects: { id: string }[],
detachedObjects: { objectId: string }[]
): string => {
const parts = [
modelsAndVersionIds
@@ -86,6 +87,10 @@ function createTreeStateManager() {
selectedObjects
.map((o) => o.id)
.sort()
.join(','),
detachedObjects
.map((o) => o.objectId)
.sort()
.join(',')
]
return parts.join('#')
@@ -115,6 +120,7 @@ function createTreeStateManager() {
selectedObjects: { id: string }[],
worldTree: WorldTree | null,
stateResourceItems: { objectId: string; modelId?: string }[],
detachedObjects: { objectId: string }[],
getRootNodesForModel: (
modelId: string,
worldTree: WorldTree | null,
@@ -134,7 +140,8 @@ function createTreeStateManager() {
modelsAndVersionIds,
expandedModels,
expandedNodes,
selectedObjects
selectedObjects,
detachedObjects
)
if (lastCacheKey.value === cacheKey && flattenedTreeCache.has(cacheKey)) {
@@ -177,6 +184,44 @@ function createTreeStateManager() {
}
})
// Handle detached objects
detachedObjects.forEach((detachedObject, index) => {
const objectId = detachedObject.objectId
const isFirstDetachedObject = index === 0 && modelsAndVersionIds.length === 0
result.push({
type: 'detached-object-header',
id: `detached-${objectId}`,
modelId: objectId, // Use objectId as modelId for detached objects
data: { objectId },
isFirstModel: isFirstDetachedObject
})
if (expandedModels.has(objectId)) {
const detachedRootNodes = getRootNodesForModel(
objectId,
worldTree,
stateResourceItems,
modelsAndVersionIds
)
if (detachedRootNodes.length > 0) {
const treeItems = flattenModelTree(
detachedRootNodes,
objectId,
expandedNodes,
selectedObjects
)
if (treeItems.length > 0) {
treeItems[0].isFirstChildOfModel = true
treeItems[treeItems.length - 1].isLastChildOfModel = true
result.push(...treeItems)
}
}
}
})
// Cache the result
flattenedTreeCache.set(cacheKey, result)
lastCacheKey.value = cacheKey
@@ -11,6 +11,7 @@ import {
useInjectedViewerInterfaceState,
useInjectedViewerState
} from '~~/lib/viewer/composables/setup'
import { ViewerRenderPageType } from '~/lib/viewer/helpers/state'
import { useDiffBuilderUtilities } from '~~/lib/viewer/composables/setup/diff'
import { getKeyboardShortcutTitle, onKeyboardShortcut } from '@speckle/ui-components'
import { ViewerShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts'
@@ -355,10 +356,13 @@ export function useConditionalViewerRendering() {
export function useHighlightedObjectsUtilities() {
const {
ui: { highlightedObjectIds }
ui: { highlightedObjectIds },
pageType
} = useInjectedViewerState()
const highlightObjects = (ids: string[]) => {
if (pageType.value === ViewerRenderPageType.Presentation) return
highlightedObjectIds.value = [...new Set([...highlightedObjectIds.value, ...ids])]
}
@@ -476,6 +476,7 @@ export const useWorkspaceUpdateRole = () => {
}
)
}
modifyObjectField(
cache,
getCacheId('Workspace', input.workspaceId),
-14
View File
@@ -230,20 +230,6 @@ export default defineNuxtConfig({
}
},
// Redirect old settings - End
'/settings/**': {
appMiddleware: ['auth', 'settings']
},
'/settings/server/*': {
appMiddleware: ['auth', 'settings', 'admin']
},
'/settings/workspaces/:slug/*': {
appMiddleware: [
'auth',
'settings',
'requires-workspaces-enabled',
'require-valid-workspace'
]
},
'/downloads': {
redirect: {
to: 'https://www.speckle.systems/connectors',
@@ -0,0 +1,10 @@
<template>
<NuxtPage />
</template>
<script setup lang="ts">
definePageMeta({
middleware: ['auth', 'settings', 'admin'],
layout: 'settings'
})
</script>
@@ -0,0 +1,10 @@
<template>
<NuxtPage />
</template>
<script setup lang="ts">
definePageMeta({
middleware: ['auth', 'settings'],
layout: 'settings'
})
</script>
@@ -0,0 +1,15 @@
<template>
<NuxtPage />
</template>
<script setup lang="ts">
definePageMeta({
middleware: [
'auth',
'settings',
'requires-workspaces-enabled',
'require-valid-workspace'
],
layout: 'settings'
})
</script>
@@ -3,6 +3,7 @@
<Portal to="navigation">
<div class="flex items-center">
<HeaderNavLink
v-if="isLoggedIn"
:to="dashboardsRoute(workspace?.slug)"
name="Dashboard"
:separator="false"
@@ -10,8 +11,10 @@
<HeaderNavLink
:to="dashboardRoute(workspace?.slug, id as string)"
:name="dashboard?.name"
:separator="isLoggedIn ? true : false"
/>
<FormButton
v-if="canEdit && !hasDashboardToken"
v-tippy="'Edit name'"
size="sm"
color="subtle"
@@ -24,7 +27,11 @@
</Portal>
<Portal to="primary-actions">
<div class="flex items-center gap-2">
<DashboardsShare :id="dashboard?.id" :workspace-slug="workspace?.slug" />
<DashboardsShare
v-if="canEdit && !hasDashboardToken"
:id="dashboard?.id"
:workspace-slug="workspace?.slug"
/>
<FormButton
v-tippy="'Toggle fullscreen'"
size="sm"
@@ -77,6 +84,11 @@ graphql(`
slug
logo
}
permissions {
canEdit {
...FullPermissionCheckResult
}
}
}
`)
@@ -93,14 +105,19 @@ const { isDarkTheme } = useTheme()
const {
public: { dashboardsOrigin }
} = useRuntimeConfig()
const { isLoggedIn } = useActiveUser()
const editDialogOpen = ref(false)
const hasDashboardToken = computed(() => !!dashboardToken.value)
const canEdit = computed(
() => result.value?.dashboard?.permissions?.canEdit?.authorized
)
const workspace = computed(() => result.value?.dashboard?.workspace)
const dashboard = computed(() => result.value?.dashboard)
const dashboardUrl = computed(
() =>
`${dashboardsOrigin}/dashboards/${id}?token=${
`${dashboardsOrigin}/${dashboardToken.value ? 'view' : 'dashboards'}/${id}?token=${
dashboardToken.value || effectiveAuthToken.value
}&isEmbed=true&theme=${isDarkTheme.value ? 'dark' : 'light'}`
)
@@ -54,13 +54,14 @@ export const useIntercom = () => {
// Hide default launcher on viewer routes (/models/)
const isViewerRoute = route.path.includes('/models/')
const isPresentationRoute = route.path.includes('/presentations/')
Intercom({
/* eslint-disable camelcase */
app_id: intercomAppId,
user_id: user.value.id || '',
created_at: Math.floor(new Date(user.value.createdAt || '').getTime() / 1000),
hide_default_launcher: isViewerRoute,
hide_default_launcher: isViewerRoute || isPresentationRoute,
/* eslint-enable camelcase */
name: user.value.name || '',
email: user.value.email || ''
@@ -104,9 +105,11 @@ export const useIntercom = () => {
if (!isInitialized.value) return
const isViewerRoute = route.path.includes('/models/')
const isPresentationRoute = route.path.includes('/presentations/')
update({
/* eslint-disable camelcase */
hide_default_launcher: isViewerRoute
hide_default_launcher: isViewerRoute || isPresentationRoute
/* eslint-enable camelcase */
})
}
@@ -17,7 +17,7 @@ describe('BatchingQueue disposal', () => {
await queue.disposeAsync()
expect(processFunction).toHaveBeenCalledWith(items)
expect(processFunction).not.toHaveBeenCalled()
expect(queue.count()).toBe(0)
expect(queue.isDisposed()).toBe(true)
})
@@ -52,8 +52,6 @@ describe('BatchingQueue disposal', () => {
resolveProcess()
await disposePromise
expect(processFunction).toHaveBeenCalledTimes(2)
expect(processFunction).toHaveBeenCalledWith(items2)
expect(queue.count()).toBe(0)
expect(queue.isDisposed()).toBe(true)
})
@@ -1,4 +1,3 @@
import { CustomLogger } from '../types/functions.js'
import KeyedQueue from './keyedQueue.js'
/**
@@ -13,7 +12,6 @@ export default class BatchingQueue<T> {
#processFunction: (batch: T[]) => Promise<void>
#timeoutId: ReturnType<typeof setTimeout> | null = null
#isProcessing = false
#logger: CustomLogger
#disposed = false
#batchTimeout: number
@@ -41,12 +39,10 @@ export default class BatchingQueue<T> {
batchSize: number
maxWaitTime: number
processFunction: (batch: T[]) => Promise<void>
logger?: CustomLogger
}) {
this.#batchSize = params.batchSize
this.#processFunction = params.processFunction
this.#batchTimeout = params.maxWaitTime
this.#logger = params.logger || ((): void => {})
}
async disposeAsync(): Promise<void> {
@@ -106,10 +102,10 @@ export default class BatchingQueue<T> {
if (this.#isProcessing || this.#queue.size === 0) {
return
}
if (this.#disposed) return
this.#isProcessing = true
const batchToProcess = this.#getBatch(this.#batchSize)
if (this.#disposed) return
try {
await this.#processFunction(batchToProcess)
+3
View File
@@ -635,6 +635,9 @@ const getStream = () => {
// Half a million circles + others stuff
// 'https://app.speckle.systems/projects/18d51359fe/models/9f4c5f5947'
// Revit v3 instances
// 'https://app.speckle.systems/projects/03074a2834/models/a013d06fe1@cc11e1ead1'
)
}
@@ -706,7 +706,9 @@ export default class SpeckleConverter {
instanced: true
})
this.addNode(instancedNode, transformNode)
await this.convertToNode(speckleData, instancedNode)
/** Alex 16.09.2025: We're adding support for instance proxies that are not direct displayable types */
// await this.convertToNode(speckleData, instancedNode)
await this.displayableLookup(speckleData, instancedNode, true)
}
}