Merge branch 'main' into resolve-infinite-reactivity-loops-in-models-panel

This commit is contained in:
andrewwallacespeckle
2025-09-17 13:07:11 +01:00
36 changed files with 1246 additions and 651 deletions
+4 -2
View File
@@ -27,8 +27,10 @@ jobs:
image: speckle/pre-commit-runner:latest
env:
IMAGE_VERSION_TAG: ${{ inputs.IMAGE_VERSION_TAG }}
DOCKER_REG_USER: ${{ inputs.DOCKERHUB_USERNAME }}
DOCKER_REG_PASS: ${{ secrets.DOCKERHUB_TOKEN }}
REGISTRY_USERNAME: ${{ inputs.DOCKERHUB_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
HELM_REGISTRY_DOMAIN: registry-1.docker.io
HELM_REPOSITORY_PATH: speckle
steps:
- uses: actions/checkout@v4.2.2
with:
@@ -6,14 +6,26 @@ if [[ -z "${IMAGE_VERSION_TAG}" ]]; then
echo "IMAGE_VERSION_TAG is not set"
exit 1
fi
if [[ -z "${DOCKER_REG_USER}" ]]; then
echo "DOCKER_REG_USER is not set"
if [[ -z "${REGISTRY_USERNAME}" ]]; then
echo "REGISTRY_USERNAME is not set"
exit 1
fi
if [[ -z "${DOCKER_REG_PASS}" ]]; then
echo "DOCKER_REG_PASS is not set"
if [[ -z "${REGISTRY_PASSWORD}" ]]; then
echo "REGISTRY_PASSWORD is not set"
exit 1
fi
if [[ -z "${HELM_REGISTRY_DOMAIN}" ]]; then
echo "HELM_REGISTRY_DOMAIN is not set"
exit 1
fi
if [[ -z "${HELM_REPOSITORY_PATH}" ]]; then
echo "HELM_REPOSITORY_PATH is not set"
exit 1
fi
RELEASE_VERSION="${IMAGE_VERSION_TAG}"
HELM_STABLE_BRANCH="${HELM_STABLE_BRANCH:-"main"}"
CHART_NAME="${CHART_NAME:-"speckle-server-chart"}"
echo "🏷️ Preparing envs"
@@ -22,16 +34,11 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# shellcheck disable=SC1090,SC1091
source "${SCRIPT_DIR}/common.sh"
RELEASE_VERSION="${IMAGE_VERSION_TAG}-chart"
HELM_STABLE_BRANCH="${HELM_STABLE_BRANCH:-"main"}"
DOCKER_HELM_REG_URL="${DOCKER_HELM_REG_URL:-"registry-1.docker.io"}"
DOCKER_HELM_REG_ORG="${DOCKER_HELM_REG_ORG:-"speckle"}"
CHART_NAME="${CHART_NAME:-"speckle-server"}"
echo "📌 Releasing Helm Chart version ${RELEASE_VERSION} for application version ${IMAGE_VERSION_TAG}"
echo "📌 Releasing Helm Chart for application version ${IMAGE_VERSION_TAG} to 'oci://${HELM_REGISTRY_DOMAIN}/${HELM_REPOSITORY_PATH}/${CHART_NAME}:${RELEASE_VERSION}'"
yq e -i ".docker_image_tag = \"${IMAGE_VERSION_TAG}\"" "${GIT_REPO}/utils/helm/speckle-server/values.yaml"
yq e -i ".name = \"${CHART_NAME}\"" "${GIT_REPO}/utils/helm/speckle-server/Chart.yaml"
echo "${DOCKER_REG_PASS}" | helm registry login "${DOCKER_HELM_REG_URL}" --username "${DOCKER_REG_USER}" --password-stdin
echo "${REGISTRY_PASSWORD}" | helm registry login "${HELM_REGISTRY_DOMAIN}" --username "${REGISTRY_USERNAME}" --password-stdin
helm package "${GIT_REPO}/utils/helm/speckle-server" --version "${RELEASE_VERSION}" --app-version "${IMAGE_VERSION_TAG}" --destination "/tmp"
helm push "/tmp/${CHART_NAME}-${RELEASE_VERSION}.tgz" "oci://${DOCKER_HELM_REG_URL}/${DOCKER_HELM_REG_ORG}"
helm push "/tmp/${CHART_NAME}-${RELEASE_VERSION}.tgz" "oci://${HELM_REGISTRY_DOMAIN}/${HELM_REPOSITORY_PATH}"
@@ -37,8 +37,8 @@ import {
ExclamationTriangleIcon
} from '@heroicons/vue/24/outline'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { useSelectionUtilities } from '~~/lib/viewer/composables/ui'
import { useFilterUtilities } from '~/lib/viewer/composables/filtering/filtering'
import { useFilterColoringHelpers } from '~/lib/viewer/composables/filtering/coloringHelpers'
import type { NumericPropertyInfo } from '@speckle/viewer'
import { containsAll } from '~~/lib/common/helpers/utils'
import type { Automate } from '@speckle/shared'
@@ -55,29 +55,32 @@ const props = defineProps<{
const {
viewer: {
metadata: { filteringState, filteringDataStore }
metadata: { filteringDataStore }
}
} = useInjectedViewerState()
const { isolateObjects, resetFilters, addActiveFilter, toggleFilterApplied, filters } =
const { isolateObjects, resetFilters, resetIsolations, addActiveFilter, filters } =
useFilterUtilities()
const { setSelectionFromObjectIds, clearSelection } = useSelectionUtilities()
const { setColorFilter, removeColorFilter } = useFilterColoringHelpers()
const logger = useLogger()
const hasMetadataGradient = computed(() => {
if (props.result.metadata?.gradient) return true
return false
})
const isolatedObjects = computed(() => filteringState.value?.isolatedObjects)
const isIsolated = computed(() => {
if (!isolatedObjects.value?.length) return false
if (
props.functionId &&
filteringState.value?.activePropFilterKey === props.functionId
)
return false
// Gradient results show active via metadataGradientIsSet
if (hasMetadataGradient.value) {
return metadataGradientIsSet.value
}
// Non-gradient results show active if their objects are isolated
const isolatedIds = filters.isolatedObjectIds.value
const ids = resultObjectIds.value
return containsAll(ids, isolatedObjects.value)
if (!isolatedIds?.length) return false
return containsAll(ids, isolatedIds)
})
const resultObjectIds = computed(() => {
@@ -90,20 +93,36 @@ const handleClick = () => {
setOrUnsetGradient()
return
}
isolateOrUnisolateObjects()
}
const isolateOrUnisolateObjects = () => {
const ids = resultObjectIds.value
const isCurrentlyIsolated = isIsolated.value
const wasIsolated = containsAll(ids, filters.isolatedObjectIds.value || [])
resetFilters()
if (isCurrentlyIsolated) {
clearSelection()
} else {
logger.debug('Isolation toggle:', {
resultCategory: props.result.category,
objectCount: ids.length,
wasIsolated,
currentIsolatedCount: filters.isolatedObjectIds.value?.length || 0
})
// Always clear existing isolation (radio button behavior)
resetIsolations()
logger.debug(
'After reset, isolated count:',
filters.isolatedObjectIds.value?.length || 0
)
// If wasn't isolated before, isolate now (toggle behavior)
if (!wasIsolated) {
isolateObjects(ids)
setSelectionFromObjectIds(ids)
logger.debug(
'After isolate, isolated count:',
filters.isolatedObjectIds.value?.length || 0
)
} else {
logger.debug('Result was isolated, now deactivated')
}
}
@@ -154,11 +173,20 @@ const computedFilterData = computed((): NumericFilterData | undefined => {
})
const setOrUnsetGradient = () => {
logger.debug('Gradient toggle:', {
resultCategory: props.result.category,
isCurrentlySet: metadataGradientIsSet.value
})
if (metadataGradientIsSet.value) {
logger.debug('Removing gradient filter')
resetFilters()
removeColorFilter()
metadataGradientIsSet.value = false
return
}
logger.debug('Setting gradient filter')
resetFilters()
if (!props.result.metadata) return
if (!computedPropInfo.value) return
@@ -169,7 +197,8 @@ const setOrUnsetGradient = () => {
metadataGradientIsSet.value = true
const filterId = addActiveFilter(computedPropInfo.value)
toggleFilterApplied(filterId)
setColorFilter(filterId)
}
const iconAndColor = computed(() => {
@@ -0,0 +1,171 @@
<template>
<div class="relative">
<div class="h-screen w-screen flex flex-col md:flex-row relative">
<PresentationCloseMessage
class="absolute z-50 top-4 left-1/2 -translate-x-1/2"
:show-close-message="showCloseMessage"
@hide-close-message="hideCloseMessage"
/>
<PresentationHeader
v-if="!hideUi"
v-model:is-sidebar-open="isLeftSidebarOpen"
class="absolute top-4 z-40"
:class="[
isLeftSidebarOpen ? 'left-[calc(50%+0.75rem)] md:left-[15.75rem]' : 'left-4'
]"
:title="presentation?.title"
@toggle-sidebar="isLeftSidebarOpen = !isLeftSidebarOpen"
/>
<PresentationActions
v-if="!hideUi"
v-model:is-sidebar-open="isInfoSidebarOpen"
v-model:is-present-mode="isPresentMode"
:presentation-id="presentation?.id"
class="absolute top-4 right-4 z-20"
:class="{ 'lg:right-[21rem]': isInfoSidebarOpen }"
@toggle-sidebar="isInfoSidebarOpen = !isInfoSidebarOpen"
@toggle-present-mode="isPresentMode = !isPresentMode"
/>
<PresentationSlideIndicator
class="absolute top-1/2 -translate-y-1/2 z-20"
:current-slide-index="currentVisibleIndex"
:slide-count="slideCount || 0"
:class="[isLeftSidebarOpen ? 'lg:left-[15.75rem] hidden md:block' : 'left-4']"
/>
<PresentationLeftSidebar
v-if="isLeftSidebarOpen"
class="absolute left-0 top-0 md:relative flex-shrink-0 z-30"
:slides="presentation"
:workspace-logo="workspace?.logo"
:workspace-name="workspace?.name"
:current-slide-id="currentView?.id"
:is-present-mode="isPresentMode"
@select-slide="onSelectSlide"
/>
<div class="flex-1 z-0">
<Component
:is="presentation ? ViewerWrapper : 'div'"
:group="presentation"
class="h-full w-full object-cover"
/>
</div>
<PresentationInfoSidebar
v-if="isInfoSidebarOpen"
class="flex-shrink-0"
:title="currentView?.name"
:description="currentView?.description"
:view-id="currentView?.id"
:project-id="projectId"
:is-present-mode="isPresentMode"
/>
<PresentationControls
v-if="!hideUi"
class="z-10 absolute left-1/2 -translate-x-1/2"
:disable-previous="disablePrevious"
:disable-next="disableNext"
:class="[
isInfoSidebarOpen ? 'bottom-52 md:bottom-4' : 'bottom-4',
isLeftSidebarOpen ? 'hidden md:flex' : ''
]"
@on-previous="onPrevious"
@on-next="onNext"
/>
<PresentationSpeckleLogo
class="absolute right-4 z-20"
:class="[isInfoSidebarOpen ? 'bottom-52 md:bottom-4' : 'bottom-4']"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
import { clamp } from 'lodash-es'
const {
projectId,
response: { presentation, workspace, visibleSlides },
ui: { slideIdx: currentVisibleIndex, slide: currentView }
} = useInjectedPresentationState()
const isInfoSidebarOpen = ref(true)
const isLeftSidebarOpen = ref(true)
const hideUi = ref(false)
const isPresentMode = ref(false)
const showCloseMessage = ref(false)
const closeMessageTimeout = ref<NodeJS.Timeout | undefined>()
const ViewerWrapper = resolveComponent('PresentationViewerWrapper')
const slideCount = computed(() => visibleSlides.value?.length || 0)
const disablePrevious = computed(() => currentVisibleIndex.value === 0)
const disableNext = computed(() =>
slideCount.value ? currentVisibleIndex.value === slideCount.value - 1 : false
)
const onSelectSlide = (slideId: string) => {
currentVisibleIndex.value = visibleSlides.value.findIndex((s) => s.id === slideId)
}
const onPrevious = () => {
currentVisibleIndex.value = clamp(currentVisibleIndex.value - 1, 0, slideCount.value)
}
const onNext = () => {
currentVisibleIndex.value = clamp(currentVisibleIndex.value + 1, 0, slideCount.value)
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'i' || event.key === 'I') {
hideUi.value = !hideUi.value
isLeftSidebarOpen.value = false
isInfoSidebarOpen.value = false
} else if (event.key === 'Escape' && isPresentMode.value) {
isPresentMode.value = false
} else if (event.key === 'ArrowLeft' && !disablePrevious.value) {
onPrevious()
} else if (event.key === 'ArrowRight' && !disableNext.value) {
onNext()
}
}
const hideCloseMessage = () => {
showCloseMessage.value = false
if (closeMessageTimeout.value) {
clearTimeout(closeMessageTimeout.value)
closeMessageTimeout.value = undefined
}
}
watch(isPresentMode, (newVal, oldVal) => {
if (newVal && !oldVal) {
isLeftSidebarOpen.value = false
isInfoSidebarOpen.value = false
showCloseMessage.value = true
closeMessageTimeout.value = setTimeout(() => {
showCloseMessage.value = false
}, 3000)
} else if (!newVal && oldVal) {
isLeftSidebarOpen.value = true
isInfoSidebarOpen.value = true
hideCloseMessage()
}
})
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
hideCloseMessage()
})
</script>
@@ -0,0 +1,51 @@
<template>
<div class="presentation-state-setup">
<slot />
</div>
</template>
<script setup lang="ts">
import { writableAsyncComputed } from '~/lib/common/composables/async'
import {
useSetupPresentationState,
type InjectablePresentationState,
type UseSetupPresentationParams
} from '~/lib/presentations/composables/setup'
const emit = defineEmits<{
setup: [InjectablePresentationState]
}>()
const route = useRoute()
const router = useSafeRouter()
const projectId = writableAsyncComputed({
get: () => route.params.id as string,
set: async (value) => {
await router.push(() => ({
params: { id: value }
}))
},
initialState: route.params.id as string,
asyncRead: false
})
const presentationId = writableAsyncComputed({
get: () => route.params.presentationId as string,
set: async (value) => {
await router.push(() => ({
params: { presentationId: value }
}))
},
initialState: route.params.presentationId as string,
asyncRead: false
})
const initParams = computed(
(): UseSetupPresentationParams => ({
projectId,
presentationId
})
)
const state = useSetupPresentationState(initParams.value)
emit('setup', state)
</script>
@@ -0,0 +1,6 @@
<template>
<div class="presentation-viewer-setup">
<ViewerCoreSetup viewer-host-classes="h-[100dvh]" />
</div>
</template>
<script setup lang="ts"></script>
@@ -0,0 +1,80 @@
<template>
<ViewerStateSetup :init-params="initParams">
<PresentationViewerSetup />
</ViewerStateSetup>
</template>
<script setup lang="ts">
import { resourceBuilder } from '@speckle/shared/viewer/route'
import { writableAsyncComputed } from '~/lib/common/composables/async'
import { graphql } from '~/lib/common/generated/gql/gql'
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
import { ViewerRenderPageType } from '~/lib/viewer/helpers/state'
import type { UseSetupViewerParams } from '~~/lib/viewer/composables/setup'
/**
* Don't put much logic here, the viewer state is unavailable here so do as much as u can in PresentationViewerSetup instead
*/
graphql(`
fragment PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {
id
views(input: $input) {
totalCount
items {
id
resourceIdString
}
}
}
`)
const {
projectId,
viewer: { resourceIdString: coreResourceIdString },
ui: { slide }
} = useInjectedPresentationState()
const route = useRoute()
// Resolved from the presentation's views, but also has to be at least somewhat mutable
// cause some viewer internals try to change it
const resourceIdString = writableAsyncComputed({
get: () => coreResourceIdString.value,
set: async (newVal) => {
const newResources = resourceBuilder().addResources(newVal).toString()
const currentResources = coreResourceIdString.value
if (newResources === currentResources) return
// we don't want this to happen, so lets log it
// to see the cause and make it not do that
devTrace('Unexpected resourceIdString mutation', {
newVal: newResources,
oldVal: currentResources
})
},
initialState: route.params.modelId as string,
asyncRead: false
})
const savedViewId = computed({
get: () => slide.value?.id || null,
set: (newVal) => {
// we don't want this to happen, so lets log it
// to see the cause and make it not do that
devTrace('Unexpected savedViewId mutation', { newVal, oldVal: slide.value?.id })
}
})
const loadOriginal = ref(false)
const initParams = computed(
(): UseSetupViewerParams => ({
projectId,
resourceIdString,
pageType: ViewerRenderPageType.Presentation,
savedView: {
id: savedViewId,
loadOriginal
}
})
)
</script>
@@ -7,7 +7,7 @@
</template>
<script setup lang="ts">
import { useInjectedViewer } from '~~/lib/viewer/composables/setup'
import { useCommentContext } from '~~/lib/viewer/composables/commentManagement'
import { useResizeObserver } from '@vueuse/core'
const rendererparent = ref<HTMLElement>()
const {
@@ -16,8 +16,6 @@ const {
init: { promise: isInitializedPromise }
} = useInjectedViewer()
const { cleanupThreadContext } = useCommentContext()
onMounted(async () => {
if (!import.meta.client) return
@@ -26,14 +24,16 @@ onMounted(async () => {
rendererparent.value?.appendChild(container)
viewer.resize()
// Not needed
// viewer.cameraHandler.onWindowResize()
})
onBeforeUnmount(() => {
if (!import.meta.client) return
container.style.display = 'none'
cleanupThreadContext()
document.body.appendChild(container)
})
useResizeObserver(rendererparent, () => {
if (!import.meta.client) return
viewer.resize()
})
</script>
@@ -0,0 +1,26 @@
<template>
<div class="viewer-core-setup flex-1 relative">
<div
id="viewer"
class="viewer special-gradient absolute z-10 overflow-hidden left-0 right-0"
:class="viewerHostClasses"
>
<ClientOnly><ViewerBase /></ClientOnly>
<slot name="after-viewer-base" />
</div>
<!-- Global loading bar -->
<ViewerLoadingBar :class="loadingBarClasses" />
</div>
</template>
<script setup lang="ts">
/**
* Sets up the actual core viewer renderer (core UI that's shared across various viewer rendering pages like
* the viewer, saved view presentations etc.)
*/
defineProps<{
viewerHostClasses?: string
loadingBarClasses?: string
}>()
</script>
@@ -1,11 +1,6 @@
<template>
<div :class="`${loadProgress < 1 && loading ? 'mt-0' : '-mt-5'} transition-all`">
<div
v-show="loading"
:class="`absolute w-full max-w-screen flex justify-center ${
!isEmbedEnabled ? 'mt-14' : 'mt-0'
} z-50`"
>
<div :class="containerClasses">
<div v-show="loading" class="absolute w-full max-w-screen flex justify-center z-50">
<div
class="relative bg-blue-500/50 mt-0 h-4 rounded-b-lg select-none px-2 py-1 w-2/3 lg:w-1/3 overflow-hidden"
>
@@ -23,8 +18,19 @@
</div>
</template>
<script setup lang="ts">
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
import { useInjectedViewerInterfaceState } from '~~/lib/viewer/composables/setup'
const { isEnabled: isEmbedEnabled } = useEmbed()
const { loading, loadProgress } = useInjectedViewerInterfaceState()
const containerClasses = computed(() => {
const classParts = ['absolute left-0 right-0 z-40 h-30', 'transition-all']
if (loadProgress.value < 1 && loading.value) {
classParts.push('mt-0')
} else {
classParts.push('-mt-5')
}
return classParts.join(' ')
})
</script>
@@ -1,108 +1,98 @@
<template>
<div>
<ViewerPostSetupWrapper>
<div class="flex-1">
<!-- Nav -->
<Portal to="navigation">
<ViewerScope :state="state">
<template v-if="project?.workspace && isWorkspacesEnabled">
<HeaderNavLink
:to="workspaceRoute(project?.workspace.slug)"
name="Projects"
:separator="false"
/>
</template>
<div class="flex-1">
<!-- Nav -->
<Portal to="navigation">
<ViewerScope :state="state">
<template v-if="project?.workspace && isWorkspacesEnabled">
<HeaderNavLink
v-else
:to="projectsRoute"
:to="workspaceRoute(project?.workspace.slug)"
name="Projects"
:separator="false"
/>
<HeaderNavLink :to="`/projects/${project?.id}`" :name="project?.name" />
<ViewerExplorerNavbarLink />
</ViewerScope>
</Portal>
<ClientOnly>
<!-- Viewer host -->
<div
id="viewer"
class="viewer special-gradient absolute z-10 overflow-hidden w-screen"
:class="
isEmbedEnabled
? isTransparent
? 'viewer-transparent h-[100dvh]'
: 'h-[calc(100dvh-3.5rem)]'
: 'h-[100dvh]'
"
>
<ViewerBase />
<Transition
enter-from-class="opacity-0"
enter-active-class="transition duration-1000"
>
<ViewerAnchoredPoints
ref="anchoredPoints"
@force-close-panels="() => closeAllPanels('threads')"
/>
</Transition>
</div>
<!-- Global loading bar -->
<ViewerLoadingBar
class="absolute left-0 w-full z-40 h-30"
:class="isEmbedEnabled ? 'top-0' : ' -top-2'"
/>
<!-- Controls -->
<!-- <ViewerControls v-if="showControls" class="relative z-20" /> -->
<template v-if="showControls">
<ViewerControlsLeft
ref="leftControls"
@force-close-panels="() => closeAllPanels('left')"
/>
<ViewerControlsBottom
ref="bottomControls"
@force-close-panels="() => closeAllPanels('bottom')"
/>
<ViewerControlsRight v-if="isMobile" />
</template>
<ViewerLimitsDialog
v-if="project"
v-model:open="showLimitsDialog"
:project="project"
:resource-id-string="resourceIdString"
:limit-type="limitsDialogType"
<HeaderNavLink
v-else
:to="projectsRoute"
name="Projects"
:separator="false"
/>
<HeaderNavLink :to="`/projects/${project?.id}`" :name="project?.name" />
<ViewerExplorerNavbarLink />
</ViewerScope>
</Portal>
<!-- Viewer Object Selection Info Display -->
<ViewerCoreSetup
:viewer-host-classes="
isEmbedEnabled
? isTransparent
? 'viewer-transparent h-[100dvh]'
: 'h-[calc(100dvh-3.5rem)]'
: 'h-[100dvh]'
"
:loading-bar-classes="isEmbedEnabled ? 'top-0' : 'top-12'"
>
<template #after-viewer-base>
<Transition
v-if="!hideSelectionInfo"
enter-from-class="opacity-0"
enter-active-class="transition duration-1000"
>
<ViewerSelectionSidebar ref="selectionSidebar" class="z-20" />
<ViewerAnchoredPoints
ref="anchoredPoints"
@force-close-panels="() => closeAllPanels('threads')"
/>
</Transition>
<div
class="absolute z-10 w-screen px-8 grid grid-cols-1 sm:grid-cols-3 gap-2 top-[3.75rem]"
>
<div class="flex items-end justify-center sm:justify-start">
<PortalTarget name="pocket-left"></PortalTarget>
</div>
<div class="flex flex-col gap-2 items-center justify-end">
<PortalTarget name="pocket-tip"></PortalTarget>
<div class="flex gap-3">
<PortalTarget name="pocket-actions"></PortalTarget>
</div>
</div>
<div class="flex items-end justify-center sm:justify-end">
<PortalTarget name="pocket-right"></PortalTarget>
</template>
</ViewerCoreSetup>
<ClientOnly>
<!-- Controls -->
<template v-if="showControls">
<ViewerControlsLeft
ref="leftControls"
@force-close-panels="() => closeAllPanels('left')"
/>
<ViewerControlsBottom
ref="bottomControls"
@force-close-panels="() => closeAllPanels('bottom')"
/>
<ViewerControlsRight v-if="isMobile" />
</template>
<ViewerLimitsDialog
v-if="project"
v-model:open="showLimitsDialog"
:project="project"
:resource-id-string="resourceIdString"
:limit-type="limitsDialogType"
/>
<!-- Viewer Object Selection Info Display -->
<Transition
v-if="!hideSelectionInfo"
enter-from-class="opacity-0"
enter-active-class="transition duration-1000"
>
<ViewerSelectionSidebar ref="selectionSidebar" class="z-20" />
</Transition>
<div
class="absolute z-10 w-screen px-8 grid grid-cols-1 sm:grid-cols-3 gap-2 top-[3.75rem]"
>
<div class="flex items-end justify-center sm:justify-start">
<PortalTarget name="pocket-left"></PortalTarget>
</div>
<div class="flex flex-col gap-2 items-center justify-end">
<PortalTarget name="pocket-tip"></PortalTarget>
<div class="flex gap-3">
<PortalTarget name="pocket-actions"></PortalTarget>
</div>
</div>
</ClientOnly>
</div>
</ViewerPostSetupWrapper>
<div class="flex items-end justify-center sm:justify-end">
<PortalTarget name="pocket-right"></PortalTarget>
</div>
</div>
</ClientOnly>
</div>
<ViewerEmbedFooter
:name="modelName || 'Loading...'"
:date="lastUpdate"
@@ -120,16 +110,12 @@
</div>
</template>
<script setup lang="ts">
import {
useSetupViewer,
type InjectableViewerState
} from '~~/lib/viewer/composables/setup'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import dayjs from 'dayjs'
import { graphql } from '~~/lib/common/generated/gql'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
import { projectsRoute, workspaceRoute } from '~~/lib/common/helpers/route'
import { useMixpanel } from '~/lib/core/composables/mp'
import { writableAsyncComputed } from '~/lib/common/composables/async'
import { parseUrlParameters, resourceBuilder } from '@speckle/shared/viewer/route'
import { ViewerLimitsDialogType } from '~/lib/projects/helpers/limits'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
@@ -155,11 +141,6 @@ graphql(`
}
`)
const emit = defineEmits<{
setup: [InjectableViewerState]
}>()
const router = useSafeRouter()
const route = useRoute()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const breakpoints = useBreakpoints(TailwindBreakpoints)
@@ -170,22 +151,9 @@ const bottomControls = ref()
const selectionSidebar = ref()
const anchoredPoints = ref()
const resourceIdString = computed(() => route.params.modelId as string)
const projectId = writableAsyncComputed({
get: () => route.params.id as string,
set: async (value: string) => {
// Just rewrite route id param
await router.push(() => ({
params: { id: value }
}))
},
initialState: route.params.id as string,
asyncRead: false
})
const state = useInjectedViewerState()
const resourceIdString = computed(() => state.resources.request.resourceIdString.value)
const state = useSetupViewer({
projectId
})
const {
isEnabled: isEmbedEnabled,
hideSelectionInfo,
@@ -196,8 +164,6 @@ const {
} = useEmbed()
const mp = useMixpanel()
emit('setup', state)
const {
resources: {
response: { project, modelsAndVersionIds }
@@ -0,0 +1,78 @@
<template>
<ViewerStateSetup :init-params="initParams" @setup="emit('setup', $event)">
<ViewerPageSetup />
</ViewerStateSetup>
</template>
<script setup lang="ts">
import { resourceBuilder } from '@speckle/shared/viewer/route'
import { writableAsyncComputed } from '~/lib/common/composables/async'
import { ViewerRenderPageType } from '~/lib/viewer/helpers/state'
import type {
InjectableViewerState,
UseSetupViewerParams
} from '~~/lib/viewer/composables/setup'
/**
* Only point of this is to prepare state so that ViewerPageSetup can inject and use it properly
*/
const emit = defineEmits<{
setup: [InjectableViewerState]
}>()
const route = useRoute()
const router = useSafeRouter()
// Resolve viewer init params
const projectId = writableAsyncComputed({
get: () => route.params.id as string,
set: async (value: string) => {
await router.push(() => ({
params: { id: value }
}))
},
initialState: route.params.id as string,
asyncRead: false
})
const resourceIdString = writableAsyncComputed({
get: () =>
resourceBuilder()
.addResources(route.params.modelId as string)
.toString(),
set: async (newVal) => {
const newResources = resourceBuilder().addResources(newVal)
const currentResources = resourceBuilder().addResources(
route.params.modelId as string
)
if (newResources.toString() === currentResources.toString()) return
const modelId = newResources.toString()
await router.push(
() => ({
params: { modelId },
query: route.query,
hash: route.hash
}),
{
skipIf: (to) => {
if (to.params.modelId !== route.params.modelId) return false
if (to.query !== route.query) return false
if (to.hash !== route.hash) return false
return true
}
}
)
},
initialState: route.params.modelId as string,
asyncRead: false
})
const initParams = computed(
(): UseSetupViewerParams => ({
projectId,
resourceIdString,
pageType: ViewerRenderPageType.Viewer
})
)
</script>
@@ -1,5 +1,5 @@
<template>
<div><slot /></div>
<div class="viewer-core-post-setup"><slot /></div>
</template>
<script setup lang="ts">
import { useViewerPostSetup } from '~~/lib/viewer/composables/setup/postSetup'
@@ -0,0 +1,26 @@
<template>
<div class="viewer-state-setup">
<ViewerStatePostSetup><slot /></ViewerStatePostSetup>
</div>
</template>
<script setup lang="ts">
import {
useSetupViewer,
type UseSetupViewerParams
} from '~/lib/viewer/composables/setup'
import type { InjectableViewerState } from '~/lib/viewer/composables/setup/core'
const emit = defineEmits<{
setup: [InjectableViewerState]
}>()
const props = defineProps<{
// Passing in a wrapper object so that the refs don't get unwrapped. We want the full
// AsyncWritableComputed objects here
initParams: UseSetupViewerParams
}>()
// initParams isnt reactive, but the refs inside of it definitely are
const state = useSetupViewer(props.initParams)
emit('setup', state)
</script>
@@ -68,6 +68,7 @@ type Documents = {
"\n fragment InviteDialogSharedSelectUsers_Workspace on Workspace {\n id\n slug\n defaultSeatType\n }\n": typeof types.InviteDialogSharedSelectUsers_WorkspaceFragmentDoc,
"\n fragment PresentationSlidesSidebar_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n items {\n ...PresentationSlidesLeftSidebarSlide_SavedView\n visibility\n id\n }\n }\n }\n": typeof types.PresentationSlidesSidebar_SavedViewGroupFragmentDoc,
"\n fragment PresentationSlidesLeftSidebarSlide_SavedView on SavedView {\n id\n name\n screenshot\n visibility\n }\n": typeof types.PresentationSlidesLeftSidebarSlide_SavedViewFragmentDoc,
"\n fragment PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n totalCount\n items {\n id\n resourceIdString\n }\n }\n }\n": typeof types.PresentationViewerPageWrapper_SavedViewGroupFragmentDoc,
"\n fragment ProjectCardImportFileArea_Project on Project {\n id\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...UseFileImport_Project\n }\n": typeof types.ProjectCardImportFileArea_ProjectFragmentDoc,
"\n fragment ProjectCardImportFileArea_Model on Model {\n id\n name\n permissions {\n canCreateVersion {\n ...FullPermissionCheckResult\n }\n }\n ...UseFileImport_Model\n }\n": typeof types.ProjectCardImportFileArea_ModelFragmentDoc,
"\n fragment ProjectInviteAdd_Project on Project {\n id\n ...InviteDialogProject_Project\n ...UseCanInviteToProject_Project\n ...WorkspaceMoveProject_Project\n }\n": typeof types.ProjectInviteAdd_ProjectFragmentDoc,
@@ -286,6 +287,7 @@ type Documents = {
"\n query NavigationWorkspaceList(\n $workspaceFilter: UserWorkspacesFilter\n $projectFilter: UserProjectsFilter\n ) {\n activeUser {\n id\n workspaces(filter: $workspaceFilter) {\n items {\n id\n ...HeaderWorkspaceSwitcherWorkspaceListItem_Workspace\n }\n }\n projects(filter: $projectFilter) {\n totalCount\n }\n }\n }\n": typeof types.NavigationWorkspaceListDocument,
"\n query NavigationProjectInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\n }\n }\n }\n": typeof types.NavigationProjectInvitesDocument,
"\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n": typeof types.NavigationWorkspaceInvitesDocument,
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n ...PresentationViewerPageWrapper_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n": typeof types.ProjectPresentationPageDocument,
"\n fragment UseCopyModelLink_Model on Model {\n id\n projectId\n ...GetModelItemRoute_Model\n }\n": typeof types.UseCopyModelLink_ModelFragmentDoc,
"\n fragment UseCanCreatePersonalProject_User on User {\n permissions {\n canCreatePersonalProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseCanCreatePersonalProject_UserFragmentDoc,
"\n fragment UseCanCreateWorkspace_User on User {\n permissions {\n canCreateWorkspace {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseCanCreateWorkspace_UserFragmentDoc,
@@ -524,8 +526,6 @@ type Documents = {
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": typeof types.ProjectPageAutomationPage_AutomationFragmentDoc,
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": typeof types.ProjectPageAutomationPage_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ProjectPageSettingsTab_ProjectFragmentDoc,
"\n fragment ProjectPresentations_SavedViewGroup on SavedViewGroup {\n id\n }\n": typeof types.ProjectPresentations_SavedViewGroupFragmentDoc,
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n": typeof types.ProjectPresentationPageDocument,
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": typeof types.SettingsServerProjects_ProjectCollectionFragmentDoc,
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": typeof types.SettingsServerRegionsDocument,
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n plan {\n status\n name\n }\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
@@ -593,6 +593,7 @@ const documents: Documents = {
"\n fragment InviteDialogSharedSelectUsers_Workspace on Workspace {\n id\n slug\n defaultSeatType\n }\n": types.InviteDialogSharedSelectUsers_WorkspaceFragmentDoc,
"\n fragment PresentationSlidesSidebar_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n items {\n ...PresentationSlidesLeftSidebarSlide_SavedView\n visibility\n id\n }\n }\n }\n": types.PresentationSlidesSidebar_SavedViewGroupFragmentDoc,
"\n fragment PresentationSlidesLeftSidebarSlide_SavedView on SavedView {\n id\n name\n screenshot\n visibility\n }\n": types.PresentationSlidesLeftSidebarSlide_SavedViewFragmentDoc,
"\n fragment PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n totalCount\n items {\n id\n resourceIdString\n }\n }\n }\n": types.PresentationViewerPageWrapper_SavedViewGroupFragmentDoc,
"\n fragment ProjectCardImportFileArea_Project on Project {\n id\n permissions {\n canCreateModel {\n ...FullPermissionCheckResult\n }\n }\n ...UseFileImport_Project\n }\n": types.ProjectCardImportFileArea_ProjectFragmentDoc,
"\n fragment ProjectCardImportFileArea_Model on Model {\n id\n name\n permissions {\n canCreateVersion {\n ...FullPermissionCheckResult\n }\n }\n ...UseFileImport_Model\n }\n": types.ProjectCardImportFileArea_ModelFragmentDoc,
"\n fragment ProjectInviteAdd_Project on Project {\n id\n ...InviteDialogProject_Project\n ...UseCanInviteToProject_Project\n ...WorkspaceMoveProject_Project\n }\n": types.ProjectInviteAdd_ProjectFragmentDoc,
@@ -811,6 +812,7 @@ const documents: Documents = {
"\n query NavigationWorkspaceList(\n $workspaceFilter: UserWorkspacesFilter\n $projectFilter: UserProjectsFilter\n ) {\n activeUser {\n id\n workspaces(filter: $workspaceFilter) {\n items {\n id\n ...HeaderWorkspaceSwitcherWorkspaceListItem_Workspace\n }\n }\n projects(filter: $projectFilter) {\n totalCount\n }\n }\n }\n": types.NavigationWorkspaceListDocument,
"\n query NavigationProjectInvites {\n activeUser {\n id\n projectInvites {\n ...HeaderNavNotificationsProjectInvite_PendingStreamCollaborator\n }\n }\n }\n": types.NavigationProjectInvitesDocument,
"\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n": types.NavigationWorkspaceInvitesDocument,
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n ...PresentationViewerPageWrapper_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n": types.ProjectPresentationPageDocument,
"\n fragment UseCopyModelLink_Model on Model {\n id\n projectId\n ...GetModelItemRoute_Model\n }\n": types.UseCopyModelLink_ModelFragmentDoc,
"\n fragment UseCanCreatePersonalProject_User on User {\n permissions {\n canCreatePersonalProject {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseCanCreatePersonalProject_UserFragmentDoc,
"\n fragment UseCanCreateWorkspace_User on User {\n permissions {\n canCreateWorkspace {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseCanCreateWorkspace_UserFragmentDoc,
@@ -1049,8 +1051,6 @@ const documents: Documents = {
"\n fragment ProjectPageAutomationPage_Automation on Automation {\n id\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectPageAutomationHeader_Automation\n ...ProjectPageAutomationFunctions_Automation\n ...ProjectPageAutomationRuns_Automation\n }\n": types.ProjectPageAutomationPage_AutomationFragmentDoc,
"\n fragment ProjectPageAutomationPage_Project on Project {\n id\n workspaceId\n ...ProjectPageAutomationHeader_Project\n }\n": types.ProjectPageAutomationPage_ProjectFragmentDoc,
"\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ProjectPageSettingsTab_ProjectFragmentDoc,
"\n fragment ProjectPresentations_SavedViewGroup on SavedViewGroup {\n id\n }\n": types.ProjectPresentations_SavedViewGroupFragmentDoc,
"\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n": types.ProjectPresentationPageDocument,
"\n fragment SettingsServerProjects_ProjectCollection on ProjectCollection {\n totalCount\n items {\n ...SettingsSharedProjects_Project\n }\n }\n": types.SettingsServerProjects_ProjectCollectionFragmentDoc,
"\n query SettingsServerRegions {\n serverInfo {\n multiRegion {\n regions {\n id\n ...SettingsServerRegionsTable_ServerRegionItem\n }\n availableKeys\n }\n }\n }\n": types.SettingsServerRegionsDocument,
"\n fragment SettingsWorkspacesGeneral_Workspace on Workspace {\n ...SettingsWorkspacesGeneralEditAvatar_Workspace\n ...SettingsWorkspaceGeneralDeleteDialog_Workspace\n ...SettingsWorkspacesGeneralEditSlugDialog_Workspace\n id\n name\n slug\n description\n logo\n role\n plan {\n status\n name\n }\n embedOptions {\n hideSpeckleBranding\n }\n permissions {\n canEditEmbedOptions {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.SettingsWorkspacesGeneral_WorkspaceFragmentDoc,
@@ -1294,6 +1294,10 @@ export function graphql(source: "\n fragment PresentationSlidesSidebar_SavedVie
* 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 PresentationSlidesLeftSidebarSlide_SavedView on SavedView {\n id\n name\n screenshot\n visibility\n }\n"): (typeof documents)["\n fragment PresentationSlidesLeftSidebarSlide_SavedView on SavedView {\n id\n name\n screenshot\n visibility\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 PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n totalCount\n items {\n id\n resourceIdString\n }\n }\n }\n"): (typeof documents)["\n fragment PresentationViewerPageWrapper_SavedViewGroup on SavedViewGroup {\n id\n views(input: $input) {\n totalCount\n items {\n id\n resourceIdString\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2166,6 +2170,10 @@ export function graphql(source: "\n query NavigationProjectInvites {\n activ
* 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 NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\n }\n }\n }\n"): (typeof documents)["\n query NavigationWorkspaceInvites {\n activeUser {\n id\n workspaceInvites {\n ...HeaderNavNotificationsWorkspaceInvite_PendingWorkspaceCollaborator\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 ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n ...PresentationViewerPageWrapper_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n ...PresentationViewerPageWrapper_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -3118,14 +3126,6 @@ export function graphql(source: "\n fragment ProjectPageAutomationPage_Project
* 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 ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment ProjectPageSettingsTab_Project on Project {\n id\n name\n permissions {\n canReadWebhooks {\n ...FullPermissionCheckResult\n }\n canReadEmbedTokens {\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 ProjectPresentations_SavedViewGroup on SavedViewGroup {\n id\n }\n"): (typeof documents)["\n fragment ProjectPresentations_SavedViewGroup on SavedViewGroup {\n id\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 ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query ProjectPresentationPage(\n $input: SavedViewGroupViewsInput!\n $savedViewGroupId: ID!\n $projectId: String!\n ) {\n project(id: $projectId) {\n id\n workspace {\n id\n name\n logo\n }\n savedViewGroup(id: $savedViewGroupId) {\n id\n title\n ...PresentationSlidesSidebar_SavedViewGroup\n views(input: $input) {\n totalCount\n items {\n id\n name\n description\n screenshot\n projectId\n visibility\n group {\n id\n }\n }\n }\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
@@ -0,0 +1,148 @@
import type { Optional } from '@speckle/shared'
import { resourceBuilder } from '@speckle/shared/viewer/route'
import type { AsyncWritableComputedRef } from '@speckle/ui-components'
import { useQuery } from '@vue/apollo-composable'
import type { Get } from 'type-fest'
import {
SavedViewVisibility,
type ProjectPresentationPageQuery
} from '~/lib/common/generated/gql/graphql'
import { projectPresentationPageQuery } from '~/lib/presentations/graphql/queries'
type ResponseProject = Optional<Get<ProjectPresentationPageQuery, 'project'>>
type ResponseWorkspace = Get<ProjectPresentationPageQuery, 'project.workspace'>
type ResponseGroup = Get<ResponseProject, 'savedViewGroup'>
type ResponseView = NonNullable<Get<ResponseGroup, 'views.items.0'>>
export type InjectablePresentationState = Readonly<{
projectId: AsyncWritableComputedRef<string>
presentationId: AsyncWritableComputedRef<string>
response: {
project: ComputedRef<ResponseProject>
workspace: ComputedRef<ResponseWorkspace>
presentation: ComputedRef<ResponseGroup>
slides: ComputedRef<ResponseView[]>
/**
* We only show public slides
*/
visibleSlides: ComputedRef<ResponseView[]>
}
ui: {
/**
* Current slide to show (0 based indexing). Indexes are based on visibleSlides, not slides.
*/
slideIdx: Ref<number>
slide: ComputedRef<ResponseView | undefined>
}
viewer: {
/**
* The actual resource id string to load in the viewer - built from presentation metadata,
* active slide etc.
*/
resourceIdString: ComputedRef<string>
}
}>
type InitState = Pick<InjectablePresentationState, 'projectId' | 'presentationId'>
type ResponseState = Pick<InjectablePresentationState, 'response'>
type UiState = Pick<InjectablePresentationState, 'ui'>
type ViewerState = Pick<InjectablePresentationState, 'viewer'>
export const InjectablePresentationStateKey: InjectionKey<InjectablePresentationState> =
Symbol('INJECTABLE_PRESENTATION_STATE')
export type UseSetupPresentationParams = {
projectId: AsyncWritableComputedRef<string>
presentationId: AsyncWritableComputedRef<string>
}
const setupStateResponse = (initState: InitState): ResponseState => {
const { result } = useQuery(projectPresentationPageQuery, () => ({
projectId: initState.projectId.value,
savedViewGroupId: initState.presentationId.value,
input: {
limit: 100
}
}))
const project = computed(() => result.value?.project)
const presentation = computed(() => project.value?.savedViewGroup)
const workspace = computed(() => project.value?.workspace)
const slides = computed(() => presentation.value?.views.items || [])
const visibleSlides = computed(() =>
slides.value.filter((view) => view.visibility === SavedViewVisibility.Public)
)
return {
response: {
project,
workspace,
presentation,
slides,
visibleSlides
}
}
}
const setupStateViewer = (initState: ResponseState & UiState): ViewerState => {
const {
response: { presentation },
ui: { slideIdx }
} = initState
const resourceIdString = computed(() => {
const slides = presentation.value?.views.items || []
return resourceBuilder()
.addResources(slides.at(slideIdx.value)?.resourceIdString || '')
.toString()
})
return { viewer: { resourceIdString } }
}
const setupStateUi = (initState: ResponseState): UiState => {
const slideIdx = ref(0)
const slide = computed(() => {
const slides = initState.response.visibleSlides.value
return slides.at(slideIdx.value)
})
return {
ui: {
slideIdx,
slide
}
}
}
export const useSetupPresentationState = (params: UseSetupPresentationParams) => {
const initState: InitState = params
const responseState = setupStateResponse(initState)
const uiState = setupStateUi(responseState)
const viewerState = setupStateViewer({ ...responseState, ...uiState })
const state: InjectablePresentationState = {
...initState,
...responseState,
...uiState,
...viewerState
}
// We don't want the state to ever be proxified (e.g. when passed through props),
// cause that will break composables (refs will be automatically unwrapped as if
// they're accessed in a template)
const rawState = markRaw(state)
provide(InjectablePresentationStateKey, rawState)
return rawState
}
export const useInjectedPresentationState = (): InjectablePresentationState => {
// we're forcing TS to ignore the scenario where this data can't be found and returns undefined
// to avoid unnecessary null checks everywhere
const state = inject(InjectablePresentationStateKey) as InjectablePresentationState
return state
}
@@ -0,0 +1,38 @@
import { graphql } from '~/lib/common/generated/gql/gql'
export const projectPresentationPageQuery = graphql(`
query ProjectPresentationPage(
$input: SavedViewGroupViewsInput!
$savedViewGroupId: ID!
$projectId: String!
) {
project(id: $projectId) {
id
workspace {
id
name
logo
}
savedViewGroup(id: $savedViewGroupId) {
id
title
...PresentationSlidesSidebar_SavedViewGroup
...PresentationViewerPageWrapper_SavedViewGroup
views(input: $input) {
totalCount
items {
id
name
description
screenshot
projectId
visibility
group {
id
}
}
}
}
}
}
`)
@@ -6,15 +6,18 @@ import {
} from '~/lib/viewer/composables/serialization'
import {
useInjectedViewerState,
type InitialSetupState
type InitialSetupState,
type UseSetupViewerParams
} from '~/lib/viewer/composables/setup'
import type { SavedViewUrlSettings } from '~/lib/viewer/helpers/savedViews'
import { ViewerRenderPageType } from '~/lib/viewer/helpers/state'
/**
* Invoke in postSetup
*/
export const useViewerSavedViewIntegration = () => {
const {
pageType,
resources: {
request: {
savedView: { id: savedViewId, loadOriginal }
@@ -77,6 +80,9 @@ export const useViewerSavedViewIntegration = () => {
}
const reset = async () => {
// No such thing as a reset in presentation mode - we always have a view active
if (pageType.value === ViewerRenderPageType.Presentation) return
savedViewId.value = null
loadOriginal.value = false
savedViewStateId.value = undefined
@@ -88,23 +94,20 @@ export const useViewerSavedViewIntegration = () => {
await update({ settings })
})
// // Apply saved view state on initial load
// useOnViewerLoadComplete(async ({ isInitial }) => {
// if (isInitial) {
// await apply()
// }
// })
// Saved view changed, apply
watch(savedView, async (newVal, oldVal) => {
if (!newVal || newVal.id === oldVal?.id) return
watch(
savedView,
async (newVal, oldVal) => {
if (!newVal || newVal.id === oldVal?.id) return
const state = validState(newVal.viewerState)
if (!state) return
const state = validState(newVal.viewerState)
if (!state) return
// If the saved view has changed, apply it
await apply()
})
// If the saved view has changed, apply it
await apply()
},
{ immediate: true }
)
watch(
() => serializedStateId.value,
@@ -136,30 +139,37 @@ export const useBuildSavedViewsUIState = () => {
}
}
export const useBuildSavedViewsCoreState = (state: InitialSetupState) => {
export const useBuildSavedViewsCoreState = (
state: InitialSetupState,
initParams: UseSetupViewerParams
) => {
const {
urlHashState: { savedView: urlHashStateSavedViewSettings }
} = state
const savedViewId = ref<string | null | undefined>(undefined)
const loadOriginal = ref<boolean>(false)
const savedViewId =
initParams?.savedView?.id || ref<string | null | undefined>(undefined)
const loadOriginal = initParams?.savedView?.loadOriginal || ref<boolean>(false)
// Usually this watcher would happen in post-setup, but its critical that this is fired
// early, before any of the GQL queries fire:
// Url hash state -> core source of truth sync
watch(
urlHashStateSavedViewSettings,
async (newVal) => {
if ((newVal?.id || null) !== (savedViewId.value || null)) {
savedViewId.value = newVal?.id || null
}
// Dont care about urlHashState in presentation mode
if (state.pageType.value !== ViewerRenderPageType.Presentation) {
// Usually this watcher would happen in post-setup, but its critical that this is fired
// early, before any of the GQL queries fire:
// Url hash state -> core source of truth sync
watch(
urlHashStateSavedViewSettings,
async (newVal) => {
if ((newVal?.id || null) !== (savedViewId.value || null)) {
savedViewId.value = newVal?.id || null
}
if ((newVal?.loadOriginal || false) !== loadOriginal.value) {
loadOriginal.value = newVal?.loadOriginal || false
}
},
{ immediate: true }
)
if ((newVal?.loadOriginal || false) !== loadOriginal.value) {
loadOriginal.value = newVal?.loadOriginal || false
}
},
{ immediate: true }
)
}
return {
id: savedViewId,
@@ -100,6 +100,7 @@ import { useViewModesSetup } from '~/lib/viewer/composables/setup/viewMode'
import { useMeasurementsSetup } from '~/lib/viewer/composables/setup/measurements'
import { useFiltersSetup } from '~/lib/viewer/composables/setup/filters'
import { useViewerPanelsSetup } from '~/lib/viewer/composables/setup/panels'
import { ViewerRenderPageType } from '~/lib/viewer/helpers/state'
export type LoadedModel = NonNullable<
Get<ViewerLoadedResourcesQuery, 'project.models.items[0]'>
@@ -124,6 +125,13 @@ export type InjectableViewerState = Readonly<{
* This is used to ignore user activity messages from the same tab.
*/
sessionId: ComputedRef<string>
/**
* The type of page that this state is powering. Based on this, certain features/UIs
* can be toggled.
*
* Default: Viewer (main viewer page), but can also be Presentation
*/
pageType: ComputedRef<ViewerRenderPageType>
/**
* The actual Viewer instance and related objects.
* Note: This is going to be undefined in SSR!
@@ -403,7 +411,7 @@ type CachedViewerState = Pick<
export type InitialSetupState = Pick<
InjectableViewerState,
'projectId' | 'viewer' | 'sessionId' | 'urlHashState'
'projectId' | 'viewer' | 'sessionId' | 'urlHashState' | 'pageType'
>
type InitialStateWithRequest = InitialSetupState & {
@@ -505,16 +513,20 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
public: { viewerDebug }
} = useRuntimeConfig()
const route = useRoute()
const sessionId = computed(() => nanoid())
const isInitialized = ref(false)
const { instance, initPromise, container } = useScopedState(
GlobalViewerDataKey,
createViewerDataBuilder({ viewerDebug })
createViewerDataBuilder({
viewerDebug: viewerDebug || route.query.viewerVerbose === '1'
})
) || { initPromise: Promise.resolve() }
initPromise.then(() => (isInitialized.value = true))
const hasDoneInitialLoad = ref(false)
return {
pageType: computed(() => params.pageType),
projectId: params.projectId,
sessionId,
viewer: import.meta.server
@@ -550,50 +562,22 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
/**
* Setup resource requests (tied to URL resource identifier param)
*/
function setupResourceRequest(state: InitialSetupState): InitialStateWithRequest {
const route = useRoute()
const router = useSafeRouter()
const getParam = computed(() => route.params.modelId as string)
function setupResourceRequest(
state: InitialSetupState,
params: UseSetupViewerParams
): InitialStateWithRequest {
const resourceIdString = params.resourceIdString
const resources = writableAsyncComputed({
get: () => parseUrlParameters(getParam.value),
get: () => resourceBuilder().addResources(resourceIdString.value).toResources(),
set: async (newResources) => {
const modelId = createGetParamFromResources(newResources)
await router.push(
() => ({
params: { modelId },
query: route.query,
hash: route.hash
}),
{
skipIf: (to) => {
if (to.params.modelId !== getParam.value) return false
if (to.query !== route.query) return false
if (to.hash !== route.hash) return false
return true
}
}
)
const newIdString = createGetParamFromResources(newResources)
await resourceIdString.update(newIdString)
},
initialState: [],
asyncRead: false
})
// we could use getParam, but `createGetParamFromResources` does sorting and de-duplication AFAIK
// + we can skip duplicate updates
const resourceIdString = writableAsyncComputed({
get: () => createGetParamFromResources(resources.value),
set: async (newVal) => {
const newResources = resourceBuilder().addResources(parseUrlParameters(newVal))
const currentResources = resourceBuilder().addResources(resources.value)
if (newResources.toString() === currentResources.toString()) return
await resources.update(newResources.toResources())
},
initialState: '',
asyncRead: false
})
const discussionLoadedVersionOnly = useSynchronizedCookie<boolean>(
'discussionLoadedVersionOnly',
{
@@ -638,7 +622,7 @@ function setupResourceRequest(state: InitialSetupState): InitialStateWithRequest
...state,
resources: {
request: {
savedView: useBuildSavedViewsCoreState(state),
savedView: useBuildSavedViewsCoreState(state, params),
items: resources,
resourceIdString,
threadFilters,
@@ -1052,7 +1036,11 @@ function setupResponseResourceData(
resourceIdString: resourceIdString.value
}
}),
{ keepPreviousResult: true }
() => ({
keepPreviousResult: true,
// Dont need threads when in presentation mode
enabled: state.pageType.value !== ViewerRenderPageType.Presentation
})
)
const commentThreadsMetadata = computed(
@@ -1213,13 +1201,24 @@ function setupInterfaceState(
}
}
type UseSetupViewerParams = { projectId: AsyncWritableComputedRef<string> }
export type UseSetupViewerParams = {
projectId: AsyncWritableComputedRef<string>
resourceIdString: AsyncWritableComputedRef<string>
pageType: ViewerRenderPageType
/**
* Optionally override savedView source of truth
*/
savedView?: {
id: InjectableViewerState['resources']['request']['savedView']['id']
loadOriginal: InjectableViewerState['resources']['request']['savedView']['loadOriginal']
}
}
export function useSetupViewer(params: UseSetupViewerParams): InjectableViewerState {
// Initialize full state object - each subsequent state initialization depends on
// the results of the previous ones until we have the final full object
const initState = setupInitialState(params)
const initialStateWithRequest = setupResourceRequest(initState)
const initialStateWithRequest = setupResourceRequest(initState, params)
const stateWithResources = setupResourceResponse(initialStateWithRequest)
const state: InjectableViewerState = setupInterfaceState(stateWithResources)
@@ -35,7 +35,10 @@ import {
useViewerCameraTracker,
useViewerEventListener
} from '~~/lib/viewer/composables/viewer'
import { useViewerCommentUpdateTracking } from '~~/lib/viewer/composables/commentManagement'
import {
useCommentContext,
useViewerCommentUpdateTracking
} from '~~/lib/viewer/composables/commentManagement'
import { getCacheId } from '~~/lib/common/helpers/graphql'
import {
useViewerOpenedThreadUpdateEmitter,
@@ -841,6 +844,14 @@ function useViewerCursorIntegration() {
})
}
const useCommentContextIntegration = () => {
const { cleanupThreadContext } = useCommentContext()
onBeforeUnmount(() => {
cleanupThreadContext()
})
}
export function useViewerPostSetup() {
if (import.meta.server) return
useViewerObjectAutoLoading()
@@ -866,5 +877,6 @@ export function useViewerPostSetup() {
useViewerTreeIntegration()
useViewModesPostSetup()
useHighlightingPostSetup()
useCommentContextIntegration()
setupDebugMode()
}
@@ -0,0 +1,4 @@
import { StringEnum, type StringEnumValues } from '@speckle/shared'
export const ViewerRenderPageType = StringEnum(['Viewer', 'Presentation'])
export type ViewerRenderPageType = StringEnumValues<typeof ViewerRenderPageType>
@@ -2,7 +2,7 @@
<div :class="isTransparent ? 'viewer-transparent' : ''">
<ClientOnly>
<ViewerEmbedManualLoad v-if="isManualLoad" @play="isManualLoad = false" />
<LazyViewerPreSetupWrapper v-else @setup="state = $event" />
<LazyViewerPageWrapper v-else @setup="state = $event" />
<Component
:is="state ? ViewerScope : 'div'"
:state="state"
@@ -31,10 +31,10 @@ definePageMeta({
})
const ViewerScope = resolveComponent('ViewerScope')
const route = useRoute()
const isManualLoad = ref(false)
const isTransparent = ref(false)
const route = useRoute()
const state = ref<InjectableViewerState>()
const checkUrlForEmbedManualLoadSettings = () => {
@@ -1,255 +1,11 @@
<template>
<div class="relative">
<div class="h-screen w-screen flex flex-col md:flex-row relative">
<PresentationCloseMessage
class="absolute z-50 top-4 left-1/2 -translate-x-1/2"
:show-close-message="showCloseMessage"
@hide-close-message="hideCloseMessage"
/>
<PresentationHeader
v-if="!hideUi"
v-model:is-sidebar-open="isLeftSidebarOpen"
class="absolute top-4 z-40"
:class="[
isLeftSidebarOpen ? 'left-[calc(50%+0.75rem)] md:left-[15.75rem]' : 'left-4'
]"
:title="presentation?.title"
@toggle-sidebar="isLeftSidebarOpen = !isLeftSidebarOpen"
/>
<PresentationActions
v-if="!hideUi"
v-model:is-sidebar-open="isInfoSidebarOpen"
v-model:is-present-mode="isPresentMode"
:presentation-id="presentation?.id"
class="absolute top-4 right-4 z-20"
:class="{ 'lg:right-[21rem]': isInfoSidebarOpen }"
@toggle-sidebar="isInfoSidebarOpen = !isInfoSidebarOpen"
@toggle-present-mode="isPresentMode = !isPresentMode"
/>
<PresentationSlideIndicator
class="absolute top-1/2 -translate-y-1/2 z-20"
:current-slide-index="currentVisibleIndex"
:slide-count="slideCount || 0"
:class="[isLeftSidebarOpen ? 'lg:left-[15.75rem] hidden md:block' : 'left-4']"
/>
<PresentationLeftSidebar
v-if="isLeftSidebarOpen"
class="absolute left-0 top-0 md:relative flex-shrink-0 z-30"
:slides="presentation"
:workspace-logo="workspace?.logo"
:workspace-name="workspace?.name"
:current-slide-id="currentViewId"
:is-present-mode="isPresentMode"
@select-slide="onSelectSlide"
/>
<div class="flex-1">
<ClientOnly>
<img
:src="currentView?.screenshot"
alt="Current view"
class="h-full w-full object-cover"
/>
</ClientOnly>
</div>
<PresentationInfoSidebar
v-if="isInfoSidebarOpen"
class="flex-shrink-0"
:title="currentView?.name"
:description="currentView?.description"
:view-id="currentView?.id"
:project-id="projectId"
:is-present-mode="isPresentMode"
/>
<PresentationControls
v-if="!hideUi"
class="absolute left-1/2 -translate-x-1/2"
:disable-previous="disablePrevious"
:disable-next="disableNext"
:class="[
isInfoSidebarOpen ? 'bottom-52 md:bottom-4' : 'bottom-4',
isLeftSidebarOpen ? 'hidden md:flex' : ''
]"
@on-previous="onPrevious"
@on-next="onNext"
/>
<PresentationSpeckleLogo
class="absolute right-4 z-20"
:class="[isInfoSidebarOpen ? 'bottom-52 md:bottom-4' : 'bottom-4']"
/>
</div>
</div>
<PresentationStateSetup>
<PresentationPageWrapper />
</PresentationStateSetup>
</template>
<script setup lang="ts">
import { graphql } from '~~/lib/common/generated/gql'
import { useQuery } from '@vue/apollo-composable'
import { SavedViewVisibility } from '~~/lib/common/generated/gql/graphql'
graphql(`
fragment ProjectPresentations_SavedViewGroup on SavedViewGroup {
id
}
`)
const projectPresentationPageQuery = graphql(`
query ProjectPresentationPage(
$input: SavedViewGroupViewsInput!
$savedViewGroupId: ID!
$projectId: String!
) {
project(id: $projectId) {
id
workspace {
id
name
logo
}
savedViewGroup(id: $savedViewGroupId) {
id
title
...PresentationSlidesSidebar_SavedViewGroup
views(input: $input) {
totalCount
items {
id
name
description
screenshot
projectId
visibility
group {
id
}
}
}
}
}
}
`)
definePageMeta({
layout: 'empty'
})
const route = useRoute()
const projectId = computed(() => route.params.id as string)
const savedViewGroupId = computed(() => route.params.presentationId as string)
const { result } = useQuery(projectPresentationPageQuery, () => ({
projectId: projectId.value,
savedViewGroupId: savedViewGroupId.value,
input: {
limit: 100
}
}))
const currentViewId = ref<string | undefined>()
const isInfoSidebarOpen = ref(true)
const isLeftSidebarOpen = ref(true)
const hideUi = ref(false)
const isPresentMode = ref(false)
const showCloseMessage = ref(false)
const closeMessageTimeout = ref<NodeJS.Timeout | undefined>()
const workspace = computed(() => result.value?.project.workspace)
const allSlides = computed(() => result.value?.project.savedViewGroup.views.items || [])
const visibleSlides = computed(() =>
allSlides.value.filter((view) => view.visibility === SavedViewVisibility.Public)
)
const currentView = computed(() =>
allSlides.value.find((slide) => slide.id === currentViewId.value)
)
const slideCount = computed(() => visibleSlides.value?.length || 0)
const presentation = computed(() => result.value?.project.savedViewGroup)
const currentVisibleIndex = computed(() => {
if (!currentViewId.value) return 0
return visibleSlides.value.findIndex((slide) => slide.id === currentViewId.value)
})
const disablePrevious = computed(() => currentVisibleIndex.value === 0)
const disableNext = computed(() =>
slideCount.value ? currentVisibleIndex.value === slideCount.value - 1 : false
)
const onSelectSlide = (slideId: string) => {
currentViewId.value = slideId
}
const onPrevious = () => {
const currentIndex = currentVisibleIndex.value
if (currentIndex > 0) {
const previousSlide = visibleSlides.value[currentIndex - 1]
currentViewId.value = previousSlide.id
}
}
const onNext = () => {
const currentIndex = currentVisibleIndex.value
if (currentIndex < visibleSlides.value.length - 1) {
const nextSlide = visibleSlides.value[currentIndex + 1]
currentViewId.value = nextSlide.id
}
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'i' || event.key === 'I') {
hideUi.value = !hideUi.value
isLeftSidebarOpen.value = false
isInfoSidebarOpen.value = false
} else if (event.key === 'Escape' && isPresentMode.value) {
isPresentMode.value = false
} else if (event.key === 'ArrowLeft' && !disablePrevious.value) {
onPrevious()
} else if (event.key === 'ArrowRight' && !disableNext.value) {
onNext()
}
}
const hideCloseMessage = () => {
showCloseMessage.value = false
if (closeMessageTimeout.value) {
clearTimeout(closeMessageTimeout.value)
closeMessageTimeout.value = undefined
}
}
watch(isPresentMode, (newVal, oldVal) => {
if (newVal && !oldVal) {
isLeftSidebarOpen.value = false
isInfoSidebarOpen.value = false
showCloseMessage.value = true
closeMessageTimeout.value = setTimeout(() => {
showCloseMessage.value = false
}, 3000)
} else if (!newVal && oldVal) {
isLeftSidebarOpen.value = true
isInfoSidebarOpen.value = true
hideCloseMessage()
}
})
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
// Initialize with first public slide after mount
if (visibleSlides.value && visibleSlides.value.length > 0 && !currentViewId.value) {
const firstPublicSlide = visibleSlides.value.find(
(slide) => slide.visibility === SavedViewVisibility.Public
)
if (firstPublicSlide) {
currentViewId.value = firstPublicSlide.id
}
}
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
hideCloseMessage()
})
</script>
@@ -0,0 +1,53 @@
import { createTransport } from 'nodemailer'
import type { EmailTransport } from '@/modules/emails/domain/types'
import type { Logger } from '@/observability/logging'
import { EmailTransportInitializationError } from '@/modules/emails/errors'
import type { SentMessageInfo } from 'nodemailer/lib/json-transport'
import { SentEmailDeliveryStatus } from '@/modules/emails/domain/consts'
const createJsonEchoTransporter = () => {
const newTransport = createTransport({ jsonTransport: true })
const wrappedTransporter: EmailTransport = {
sendMail: async (options) => {
const response = await newTransport.sendMail(options)
return {
messageId: response.messageId,
status: mapJsonResponseToSentEmailDeliveryStatus(response)
}
}
}
return wrappedTransporter
}
export async function initializeJSONEchoTransporter(deps: {
isSandboxMode: boolean
logger: Logger
}): Promise<EmailTransport | undefined> {
let newTransporter = undefined
newTransporter = createJsonEchoTransporter()
if (!newTransporter) {
const message =
'📧 In testing or email sandbox mode a mock email provider is enabled but transport has not initialized correctly.'
deps.logger.error(message)
throw new EmailTransportInitializationError(message)
}
return newTransporter
}
export const mapJsonResponseToSentEmailDeliveryStatus = (
response: SentMessageInfo
): SentEmailDeliveryStatus => {
if (response.rejected.length > 0) {
return SentEmailDeliveryStatus.FAILED
}
if (response.accepted.length === 0) {
return SentEmailDeliveryStatus.PENDING
}
return SentEmailDeliveryStatus.SENT
}
@@ -0,0 +1,101 @@
import { MisconfiguredEnvironmentError } from '@/modules/shared/errors'
import { createTransport } from 'nodemailer'
import type { EmailTransport } from '@/modules/emails/domain/types'
import { ensureError } from '@speckle/shared'
import type { Logger } from '@/observability/logging'
import { EmailTransportInitializationError } from '@/modules/emails/errors'
import { SentEmailDeliveryStatus } from '@/modules/emails/domain/consts'
import type { SentMessageInfo } from 'nodemailer/lib/smtp-pool'
type SMTPConfig = {
host: string
port: number
sslEnabled: boolean
tlsRequired: boolean
auth: { username: string; password: string }
}
const initSmtpTransporter = async (params: SMTPConfig & { logger: Logger }) => {
if (!params.tlsRequired && !params.sslEnabled) {
params.logger.warn(
'Neither EMAIL_SECURE and EMAIL_REQUIRE_TLS are true. Client will attempt to upgrade to TLS on connect, but will default to whatever the server supports which may be insecure.'
)
} else if (params.tlsRequired && params.sslEnabled) {
throw new MisconfiguredEnvironmentError(
'EMAIL_SECURE and EMAIL_REQUIRE_TLS cannot both be true. TLS would typically be preferred over SSL.'
)
}
const smtpTransporter = createTransport({
host: params.host,
port: params.port,
requireTLS: params.tlsRequired,
secure: params.sslEnabled,
auth: {
user: params.auth.username,
pass: params.auth.password
},
pool: true,
maxConnections: 20,
maxMessages: Infinity
})
const verifyResult = await smtpTransporter.verify()
if (!verifyResult) {
throw new EmailTransportInitializationError(
'SMTP transporter verification failed. Please check your SMTP configuration.'
)
}
const wrappedTransporter: EmailTransport = {
sendMail: async (options) => {
const response = await smtpTransporter.sendMail(options)
return {
messageId: response.messageId,
status: mapSMTPResponseToSentEmailDeliveryStatus(response),
errorMessages: response.rejectedErrors?.map((e) => `${e.code}: ${e.message}`)
}
}
}
return wrappedTransporter
}
export async function initializeSMTPTransporter(
deps: SMTPConfig & {
isSandboxMode: boolean
logger: Logger
}
): Promise<EmailTransport | undefined> {
let newTransporter = undefined
const errorMessage =
'📧 Email provider is enabled but transport has not initialized correctly. Please review the email configuration or your email system for problems.'
try {
newTransporter = await initSmtpTransporter(deps)
} catch (e) {
const err = ensureError(e, 'Unknown error while initializing SMTP transporter')
deps.logger.error(err, errorMessage)
throw new EmailTransportInitializationError(errorMessage, { cause: err })
}
if (!newTransporter) {
deps.logger.error(errorMessage)
throw new EmailTransportInitializationError(errorMessage)
}
return newTransporter
}
const mapSMTPResponseToSentEmailDeliveryStatus = (
response: SentMessageInfo
): SentEmailDeliveryStatus => {
if (response.rejected.length > 0) {
return SentEmailDeliveryStatus.FAILED
}
if (response.accepted.length === 0) {
return SentEmailDeliveryStatus.PENDING
}
return SentEmailDeliveryStatus.SENT
}
@@ -0,0 +1,44 @@
import type { EmailTransport } from '@/modules/emails/domain/types'
import type { Logger } from '@/observability/logging'
import { initializeSMTPTransporter } from '@/modules/emails/clients/smtp'
import { initializeJSONEchoTransporter } from '@/modules/emails/clients/jsonEcho'
import {
getEmailHost,
getEmailPassword,
getEmailPort,
getEmailUsername,
isSSLEmailEnabled,
isTLSEmailRequired
} from '@/modules/shared/helpers/envHelper'
let transporter: EmailTransport | undefined = undefined
export const initializeEmailTransport = async (params: {
isSandboxMode?: boolean
logger: Logger
}) => {
const { isSandboxMode = false, logger } = params
if (isSandboxMode) {
logger.info('📧 Using JSON Echo email transporter')
transporter = await initializeJSONEchoTransporter({ logger, isSandboxMode })
} else {
logger.info('📧 Using SMTP email transporter')
transporter = await initializeSMTPTransporter({
host: getEmailHost(),
port: getEmailPort(),
sslEnabled: isSSLEmailEnabled(),
tlsRequired: isTLSEmailRequired(),
auth: {
username: getEmailUsername(),
password: getEmailPassword()
},
logger,
isSandboxMode
})
}
}
export const getTransporter = (): EmailTransport | undefined => {
return transporter
}
@@ -0,0 +1,20 @@
export const EmailTransportType = {
SMTP: 'smtp',
JSONEcho: 'jsonecho'
} as const
export type EmailTransportType =
(typeof EmailTransportType)[keyof typeof EmailTransportType]
export const isEmailTransportType = (value: string): value is EmailTransportType => {
return Object.values(EmailTransportType).includes(value as EmailTransportType)
}
export const SentEmailDeliveryStatus = {
QUEUED: 'queued',
PENDING: 'pending', // addresses that received a temporary failure response from the receiving server
SENT: 'sent',
FAILED: 'failed'
} as const
export type SentEmailDeliveryStatus =
(typeof SentEmailDeliveryStatus)[keyof typeof SentEmailDeliveryStatus]
@@ -1,4 +1,5 @@
import type Mail from 'nodemailer/lib/mailer'
import type { SentEmailDeliveryStatus } from '@/modules/emails/domain/consts'
export const emailsEventNamespace = 'emails' as const
@@ -9,6 +10,10 @@ export const EmailsEvents = {
export type EmailsEvents = (typeof EmailsEvents)[keyof typeof EmailsEvents]
export type EmailsEventsPayloads = {
[EmailsEvents.Sent]: { options: Mail.Options }
[EmailsEvents.Sent]: {
options: Mail.Options
deliveryStatus: SentEmailDeliveryStatus
deliverErrorMessages?: string[]
}
[EmailsEvents.PreparingToSend]: { options: Omit<Mail.Options, 'from'> }
}
@@ -1,6 +1,32 @@
import type { SentEmailDeliveryStatus } from '@/modules/emails/domain/consts'
export type EmailVerification = {
id: string
email: string
createdAt: Date
code: string
}
export type EmailOptions = {
from?: string
to: string | string[]
subject: string
text: string
html: string
speckleEmailId?: string
}
export type SentEmailInfo = {
messageId: string
/**
* Overall status for the sent email. If multiple recipients were specified, this will be the "worst" status among them.
* For example, if one recipient's email was sent successfully but another's failed, the overall status will be "failed".
* The error messages should be checked for more granular details.
*/
status: SentEmailDeliveryStatus
errorMessages?: string[]
}
export type EmailTransport = {
sendMail: (options: EmailOptions) => Promise<SentEmailInfo>
}
@@ -11,3 +11,21 @@ export class EmailVerificationFinalizationError extends BaseError {
static defaultMessage = 'Invalid email verification finalization request'
static statusCode = 400
}
export class EmailSendingError extends BaseError {
static code = 'EMAIL_SENDING_ERROR'
static defaultMessage = 'Error sending email'
static statusCode = 500
}
export class EmailTransportInitializationError extends BaseError {
static code = 'EMAIL_TRANSPORT_INITIALIZATION_ERROR'
static defaultMessage = 'Error initializing email transport'
static statusCode = 500
}
export class MailchimpClientError extends BaseError {
static code = 'MAILCHIMP_CLIENT_ERROR'
static defaultMessage = 'Error with Mailchimp client'
static statusCode = 500
}
+16 -27
View File
@@ -1,42 +1,31 @@
/* istanbul ignore file */
import { moduleLogger } from '@/observability/logging'
import * as SendingService from '@/modules/emails/services/sending'
import { initializeTransporter } from '@/modules/emails/utils/transporter'
import { emailLogger, moduleLogger } from '@/observability/logging'
import type { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import RestApi from '@/modules/emails/rest/index'
import { isEmailEnabled, isTestEnv } from '@/modules/shared/helpers/envHelper'
import { initializeEmailTransport } from '@/modules/emails/clients/transportBuilder'
const emailsModule: SpeckleModule = {
init: async ({ app }) => {
moduleLogger.info('📧 Init emails module')
// init transporter
await initializeTransporter()
if (isEmailEnabled()) {
await initializeEmailTransport({
logger: emailLogger
})
} else if (isTestEnv()) {
await initializeEmailTransport({
isSandboxMode: true,
logger: emailLogger
})
} else {
moduleLogger.warn('📧 Email functionality is disabled')
}
// init rest api
RestApi(app)
}
}
/**
* @deprecated Use `sendEmail` from `@/modules/emails/services/sending` instead
*/
async function sendEmail({
from,
to,
subject,
text,
html
}: {
from?: string
to: string
subject: string
text: string
html: string
}) {
return SendingService.sendEmail({ from, to, subject, text, html })
}
export default {
...emailsModule,
sendEmail
...emailsModule
}
@@ -1,15 +1,18 @@
import { emailLogger } from '@/observability/logging'
import type { SendEmail, SendEmailParams } from '@/modules/emails/domain/operations'
import { getTransporter } from '@/modules/emails/utils/transporter'
import { getTransporter } from '@/modules/emails/clients/transportBuilder'
import { getEmailFromAddress } from '@/modules/shared/helpers/envHelper'
import { ensureError, resolveMixpanelUserId } from '@speckle/shared'
import {
getRequestContext,
getRequestLogger,
loggerWithMaybeContext
} from '@/observability/utils/requestContext'
import type Mail from 'nodemailer/lib/mailer'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { EmailsEvents } from '@/modules/emails/domain/events'
import type { EmailOptions } from '@/modules/emails/domain/types'
import cryptoRandomString from 'crypto-random-string'
import { SentEmailDeliveryStatus } from '@/modules/emails/domain/consts'
/**
* Send out an e-mail
@@ -23,6 +26,7 @@ export const sendEmail: SendEmail = async ({
}: SendEmailParams): Promise<boolean> => {
const eventBus = getEventBus()
const logger = getRequestLogger() || loggerWithMaybeContext({ logger: emailLogger })
const context = getRequestContext()
try {
const baseOptions = {
@@ -44,28 +48,43 @@ export const sendEmail: SendEmail = async ({
}
const emailFrom = getEmailFromAddress()
const options: Mail.Options = {
const options: EmailOptions = {
...baseOptions,
from: from || `"Speckle" <${emailFrom}>`
}
if (context && 'requestId' in context) {
// add some random digits to avoid collisions if multiple emails are sent within the same request
options.speckleEmailId = `${context.requestId}_${cryptoRandomString({
length: 4
})}`
}
await transporter.sendMail(options)
const sentEmailResponse = await transporter.sendMail(options)
await eventBus.emit({
eventName: EmailsEvents.Sent,
payload: { options }
payload: {
options,
deliveryStatus: sentEmailResponse.status,
deliverErrorMessages: sentEmailResponse.errorMessages
}
})
const emails = typeof to === 'string' ? [to] : to
const distinctIds = await Promise.all(
emails.map((email) => resolveMixpanelUserId(email))
)
logger.info(
;(sentEmailResponse.status === SentEmailDeliveryStatus.FAILED
? logger.warn
: logger.info)(
{
subject,
distinctIds
distinctIds,
deliveryStatus: sentEmailResponse.status,
deliveryErrorMessages: sentEmailResponse.errorMessages
},
'Email "{subject}" sent out to distinctIds {distinctIds}'
'Email "{subject}" sent out to distinctIds {distinctIds}; status: {deliveryStatus}'
)
return true
} catch (error) {
const err = ensureError(error, 'Unknown error when sending email')
@@ -1,95 +0,0 @@
import { emailLogger as logger } from '@/observability/logging'
import { MisconfiguredEnvironmentError } from '@/modules/shared/errors'
import {
getEmailHost,
getEmailPassword,
getEmailPort,
getEmailUsername,
isEmailEnabled,
isSSLEmailEnabled,
isTestEnv,
isTLSEmailRequired
} from '@/modules/shared/helpers/envHelper'
import type { Transporter } from 'nodemailer'
import { createTransport } from 'nodemailer'
let transporter: Transporter | undefined = undefined
const createJsonEchoTransporter = () => createTransport({ jsonTransport: true })
const initSmtpTransporter = async () => {
const sslRequired = isSSLEmailEnabled()
const tlsRequired = isTLSEmailRequired()
if (!tlsRequired && !sslRequired) {
logger.warn(
'Neither EMAIL_SECURE and EMAIL_REQUIRE_TLS are true. Client will attempt to upgrade to TLS on connect, but will default to whatever the server supports which may be insecure.'
)
} else if (sslRequired && tlsRequired) {
throw new MisconfiguredEnvironmentError(
'EMAIL_SECURE and EMAIL_REQUIRE_TLS cannot both be true. TLS would typically be preferred over SSL.'
)
}
try {
const smtpTransporter = createTransport({
host: getEmailHost(),
port: getEmailPort(),
requireTLS: tlsRequired,
secure: sslRequired,
auth: {
user: getEmailUsername(),
pass: getEmailPassword()
},
pool: true,
maxConnections: 20,
maxMessages: Infinity
})
const transporterVerified = await smtpTransporter.verify()
if (!transporterVerified) {
logger.error(
'📧 Email provider is likely misconfigured as validation failed, check config variables'
)
}
return smtpTransporter
} catch (e) {
logger.error(e, '📧 Email provider is misconfigured, check config variables.')
}
}
export async function initializeTransporter(): Promise<Transporter | undefined> {
let newTransporter = undefined
if (isEmailEnabled()) {
newTransporter = await initSmtpTransporter()
if (!newTransporter) {
const message =
'📧 Email provider is enabled but transport has not initialized correctly. Please review the email configuration or your email system for problems.'
logger.error(message)
throw new MisconfiguredEnvironmentError(message)
}
}
if (!newTransporter && isTestEnv()) {
newTransporter = createJsonEchoTransporter()
if (!newTransporter) {
const message =
'📧 In testing a mock email provider is enabled but transport has not initialized correctly.'
logger.error(message)
throw new MisconfiguredEnvironmentError(message)
}
}
if (!newTransporter) {
logger.warn(
'📧 Email provider is not configured. Server functionality will be limited.'
)
}
transporter = newTransporter
return newTransporter
}
export function getTransporter(): Transporter | undefined {
return transporter
}
@@ -1,5 +1,4 @@
import crs from 'crypto-random-string'
import emailsModule from '@/modules/emails'
import { InviteCreateValidationError } from '@/modules/serverinvites/errors'
import sanitizeHtml from 'sanitize-html'
import type { ResolvedTargetData } from '@/modules/serverinvites/helpers/core'
@@ -30,6 +29,7 @@ import type { ServerInfo } from '@/modules/core/helpers/types'
import type { EventBusEmit } from '@/modules/shared/services/eventBus'
import type { GetUser } from '@/modules/core/domain/users/operations'
import type { GetServerInfo } from '@/modules/core/domain/server/operations'
import { sendEmail } from '@/modules/emails/services/sending'
const getFinalTargetData = (
target: string,
@@ -71,7 +71,7 @@ const sendInviteEmailFactory =
)
// send email and emit event
await emailsModule.sendEmail({
await sendEmail({
subject: emailContents.subject,
to: targetUser ? targetUser.email : targetData.userEmail!,
...renderedEmail
@@ -23,7 +23,7 @@ spec:
spec:
containers:
- name: main
image: speckle/speckle-webhook-service:{{ .Values.docker_image_tag }}
image: {{ default (printf "speckle/speckle-webhook-service:%s" .Values.docker_image_tag) .Values.webhook_service.image }}
imagePullPolicy: {{ .Values.imagePullPolicy }}
ports: