Files
speckle-server/packages/frontend-2/lib/viewer/composables/setup.ts
T
Kristaps Fabians Geikins bde148f286 chore(server): migrating fully to ESM (#5042)
* wip

* some extra fixes

* stuff kinda works?

* need to figure out mocks

* need to figure out mocks

* fix db listener

* gqlgen fix

* minor gqlgen watch adjustment

* lint fixes

* delete old codegen file

* converting migrations to ESM

* getModuleDIrectory

* vitest sort of works

* added back ts-vitest

* resolve gql double load

* fixing test timeout configs

* TSC lint fix

* fix automate tests

* moar debugging

* debugging

* more debugging

* codegen update

* server works

* yargs migrated

* chore(server): getting rid of global mocks for Server ESM (#5046)

* got rid of email mock

* got rid of comment mocks

* got rid of multi region mocks

* got rid of stripe mock

* admin override mock updated

* removed final mock

* fixing import.meta.resolve calls

* another import.meta.resolve fix

* added requested test

* nyc ESM fix

* removed unneeded deps + linting

* yarn lock forgot to commit

* tryna fix flakyness

* email capture util fix

* sendEmail fix

* fix TSX check

* sender transporter fix + CR comments

* merge main fix

* test fixx

* circleci fix

* gqlgen bigint fix

* error formatter fix

* more error formatting improvements

* esmloader added to Dockerfile

* more dockerfile fixes

* bg jobs fix
2025-07-14 10:26:19 +03:00

1104 lines
33 KiB
TypeScript

import {
DefaultViewerParams,
ViewerEvent,
DefaultLightConfiguration,
LegacyViewer,
MeasurementType,
FilteringExtension
} from '@speckle/viewer'
import {
type FilteringState,
type PropertyInfo,
type SunLightConfiguration,
type SpeckleView,
type MeasurementOptions,
type DiffResult,
type Viewer,
type WorldTree,
type VisualDiffMode,
ViewMode
} from '@speckle/viewer'
import { inject, ref, provide } from 'vue'
import type { ComputedRef, WritableComputedRef, Raw, Ref, ShallowRef } from 'vue'
import { useScopedState } from '~~/lib/common/composables/scopedState'
import type { MaybeNullOrUndefined, Nullable, Optional } from '@speckle/shared'
import { SpeckleViewer, isNonNullable } from '@speckle/shared'
import { useApolloClient, useLazyQuery, useQuery } from '@vue/apollo-composable'
import {
projectViewerResourcesQuery,
viewerLoadedResourcesQuery,
viewerLoadedThreadsQuery,
viewerModelVersionsQuery
} from '~~/lib/viewer/graphql/queries'
import type {
ProjectViewerResourcesQueryVariables,
ViewerLoadedResourcesQuery,
ViewerLoadedResourcesQueryVariables,
ViewerLoadedThreadsQuery,
ViewerResourceItem,
ViewerLoadedThreadsQueryVariables,
ProjectCommentsFilter,
ViewerModelVersionCardItemFragment
} from '~~/lib/common/generated/gql/graphql'
import type { SetNonNullable, Get } from 'type-fest'
import {
convertThrowIntoFetchResult,
getFirstErrorMessage
} from '~~/lib/common/helpers/graphql'
import { nanoid } from 'nanoid'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import type { CommentBubbleModel } from '~~/lib/viewer/composables/commentBubbles'
import { setupUrlHashState } from '~~/lib/viewer/composables/setup/urlHashState'
import type { SpeckleObject } from '~/lib/viewer/helpers/sceneExplorer'
import { Vector3 } from 'three'
import { writableAsyncComputed } from '~~/lib/common/composables/async'
import type { AsyncWritableComputedRef } from '~~/lib/common/composables/async'
import { setupUiDiffState } from '~~/lib/viewer/composables/setup/diff'
import type { DiffStateCommand } from '~~/lib/viewer/composables/setup/diff'
import { useDiffUtilities, useFilterUtilities } from '~~/lib/viewer/composables/ui'
import { flatten, reduce } from 'lodash-es'
import { setupViewerCommentBubbles } from '~~/lib/viewer/composables/setup/comments'
import {
InjectableViewerStateKey,
useSetupViewerScope
} from '~/lib/viewer/composables/setup/core'
import { useSynchronizedCookie } from '~~/lib/common/composables/reactiveCookie'
import { buildManualPromise } from '@speckle/ui-components'
import { PassReader } from '../extensions/PassReader'
import type { SectionBoxData } from '@speckle/shared/viewer/state'
export type LoadedModel = NonNullable<
Get<ViewerLoadedResourcesQuery, 'project.models.items[0]'>
>
export type LoadedThreadsMetadata = NonNullable<
Get<ViewerLoadedThreadsQuery, 'project.commentThreads'>
>
export type LoadedCommentThread = NonNullable<Get<LoadedThreadsMetadata, 'items[0]'>>
export type InjectableViewerState = Readonly<{
/**
* The project which we're opening in the viewer (all loaded models should belong to it)
*/
projectId: AsyncWritableComputedRef<string>
/**
* User viewer session ID. The same user will have different IDs in different tabs if multiple are open.
* This is used to ignore user activity messages from the same tab.
*/
sessionId: ComputedRef<string>
/**
* The actual Viewer instance and related objects.
* Note: This is going to be undefined in SSR!
*/
viewer: {
/**
* The actual viewer instance
*/
instance: LegacyViewer
/**
* Container onto which the Viewer instance is attached
*/
container: HTMLElement
/**
* For checking when viewer.init() is complete
*/
init: {
promise: Promise<void>
ref: ComputedRef<boolean>
}
/**
* Various values that represent the current Viewer instance state
*/
metadata: {
/**
* Based on a shallow ref
*/
worldTree: ComputedRef<Optional<WorldTree>>
availableFilters: ComputedRef<Optional<PropertyInfo[]>>
views: ComputedRef<SpeckleView[]>
filteringState: ComputedRef<Optional<FilteringState>>
}
/**
* Whether the Viewer has finished doing the initial object loading
*/
hasDoneInitialLoad: Ref<boolean>
}
/**
* Loaded/loadable resources
*/
resources: {
/**
* State of resource identifiers that should be loaded (tied to the URL param)
*/
request: {
/**
* All currently requested identifiers. You
* can write to this to change which resources should be loaded.
*/
items: AsyncWritableComputedRef<SpeckleViewer.ViewerRoute.ViewerResource[]>
/**
* All currently requested identifiers in a comma-delimited string, the way it's
* represented in the URL. Is writable also.
*/
resourceIdString: AsyncWritableComputedRef<string>
/**
* Writable computed for reading/writing current thread filters
*/
threadFilters: Ref<Omit<ProjectCommentsFilter, 'resourceIdString'>>
/**
* Helper for switching model to a specific version (or just latest)
*/
switchModelToVersion: (modelId: string, versionId?: string) => Promise<void>
// addModelVersion: (modelId: string, versionId: string) => void
// removeModelVersion: (modelId: string, versionId?: string) => void
// setModelVersions: (newResources: ViewerResource[]) => void
}
/**
* State of resolved, validated & de-duplicated resources that are loaded in the viewer. These
* are resolved from multiple GQL requests and update whenever resources.request updates.
*/
response: {
/**
* Metadata about loaded items
*/
resourceItems: ComputedRef<ViewerResourceItem[]>
/**
* Variables used to load resource items identified by URL identifiers. Relevant when making cache updates
*/
resourceItemsQueryVariables: ComputedRef<
Optional<ProjectViewerResourcesQueryVariables>
>
/**
* Whether or not the initial resource items load has happened (useful in SSR)
*/
resourceItemsLoaded: ComputedRef<boolean>
/**
* Whether or not the initial resources (models, objects etc.) have been loaded (useful in SSR)
*/
resourcesLoaded: ComputedRef<boolean>
/**
* Model GQL objects paired with their loaded version IDs
*/
modelsAndVersionIds: ComputedRef<Array<{ model: LoadedModel; versionId: string }>>
/**
* All available (retrieved from GQL) models and their versions
*/
availableModelsAndVersions: ComputedRef<
Array<{ model: LoadedModel; versions: LoadedModel['versions']['items'] }>
>
/**
* Detached objects (not models/versions)
*/
objects: ComputedRef<ViewerResourceItem[]>
/**
* Comment threads for all loaded resources
*/
commentThreads: ComputedRef<Array<LoadedCommentThread>>
/**
* Metadata about requested comment threads (e.g. total counts)
*/
commentThreadsMetadata: ComputedRef<Optional<LoadedThreadsMetadata>>
/**
* Project main metadata
*/
project: ComputedRef<Optional<Get<ViewerLoadedResourcesQuery, 'project'>>>
/**
* Variables used to load the resource query. Relevant when making cache updates.
*/
resourceQueryVariables: ComputedRef<Optional<ViewerLoadedResourcesQueryVariables>>
/**
* Variables used to load the threads query. Relevant when making cache updates.
*/
threadsQueryVariables: ComputedRef<Optional<ViewerLoadedThreadsQueryVariables>>
/**
* Fetch the next page of versions for a loaded model
*/
loadMoreVersions: (modelId: string) => Promise<void>
resourcesLoading: ComputedRef<boolean>
}
}
/**
* Interface state
*/
ui: {
/**
* Thread and their bubble state
*/
threads: {
/**
* Comment bubble models keyed by comment ID
*/
items: Ref<Record<string, CommentBubbleModel>>
openThread: {
thread: ComputedRef<Optional<CommentBubbleModel>>
isTyping: Ref<boolean>
newThreadEditor: Ref<boolean>
}
hideBubbles: Ref<boolean>
}
spotlightUserSessionId: Ref<Nullable<string>>
filters: {
isolatedObjectIds: Ref<string[]>
hiddenObjectIds: Ref<string[]>
selectedObjects: Ref<Raw<SpeckleObject>[]>
/**
* For quick object ID lookups
*/
selectedObjectIds: ComputedRef<Set<string>>
propertyFilter: {
filter: Ref<Nullable<PropertyInfo>>
isApplied: Ref<boolean>
}
hasAnyFiltersApplied: ComputedRef<boolean>
}
camera: {
position: Ref<Vector3>
target: Ref<Vector3>
isOrthoProjection: Ref<boolean>
}
viewMode: Ref<ViewMode>
diff: {
newVersion: ComputedRef<ViewerModelVersionCardItemFragment | undefined>
oldVersion: ComputedRef<ViewerModelVersionCardItemFragment | undefined>
time: Ref<number>
mode: Ref<VisualDiffMode>
result: ShallowRef<Optional<DiffResult>> //ComputedRef<Optional<DiffResult>>
enabled: Ref<boolean>
}
sectionBox: Ref<Nullable<SectionBoxData>>
sectionBoxContext: {
visible: Ref<boolean>
edited: Ref<boolean>
}
highlightedObjectIds: Ref<string[]>
lightConfig: Ref<SunLightConfiguration>
explodeFactor: Ref<number>
viewerBusy: WritableComputedRef<boolean>
loadProgress: Ref<number>
selection: Ref<Nullable<Vector3>>
measurement: {
enabled: Ref<boolean>
options: Ref<MeasurementOptions>
}
}
/**
* State stored in the anchor string of the URL
*/
urlHashState: {
focusedThreadId: AsyncWritableComputedRef<Nullable<string>>
diff: AsyncWritableComputedRef<Nullable<DiffStateCommand>>
}
}>
type CachedViewerState = Pick<
InjectableViewerState['viewer'],
'container' | 'instance'
> & {
initPromise: Promise<void>
}
type InitialSetupState = Pick<
InjectableViewerState,
'projectId' | 'viewer' | 'sessionId' | 'urlHashState'
>
type InitialStateWithRequest = InitialSetupState & {
resources: { request: InjectableViewerState['resources']['request'] }
}
export type InitialStateWithRequestAndResponse = InitialSetupState &
Pick<InjectableViewerState, 'resources'>
export type InitialStateWithUrlHashState = InitialStateWithRequestAndResponse
export type InitialStateWithInterface = InitialStateWithUrlHashState &
Pick<InjectableViewerState, 'ui'>
/**
* Scoped state key for 'viewer' metadata, as we reuse it between routes
*/
const GlobalViewerDataKey = Symbol('GlobalViewerData')
function createViewerDataBuilder(params: { viewerDebug: boolean }) {
return () => {
if (import.meta.server)
// we don't want to use nullable checks everywhere, so the nicer route here ends
// up being telling TS to ignore the undefineds - you shouldn't use any of this in SSR anyway
return undefined as unknown as CachedViewerState
const container = document.createElement('div')
container.id = 'renderer'
container.style.display = 'block'
container.style.width = '100%'
container.style.height = '100%'
const viewer = new LegacyViewer(container, {
...DefaultViewerParams,
verbose: !!(import.meta.client && params.viewerDebug)
})
viewer.createExtension(PassReader)
const initPromise = viewer.init()
return {
instance: viewer,
container,
initPromise
}
}
}
function setupViewerMetadata(params: {
viewer: Viewer
}): InitialSetupState['viewer']['metadata'] {
const { viewer } = params
const worldTree = shallowRef(undefined as Optional<WorldTree>)
const availableFilters = shallowRef(undefined as Optional<PropertyInfo[]>)
const filteringState = shallowRef(undefined as Optional<FilteringState>)
const views = ref([] as SpeckleView[])
const refreshWorldTreeAndFilters = async (busy: boolean) => {
if (busy) return
worldTree.value = viewer.getWorldTree()
availableFilters.value = await viewer.getObjectProperties()
views.value = viewer.getViews()
}
const updateFilteringState = (newState: FilteringState) => {
filteringState.value = newState
}
onMounted(() => {
viewer.on(ViewerEvent.Busy, refreshWorldTreeAndFilters)
viewer
.getExtension(FilteringExtension)
.on(ViewerEvent.FilteringStateSet, updateFilteringState)
})
onBeforeUnmount(() => {
viewer.removeListener(ViewerEvent.Busy, refreshWorldTreeAndFilters)
viewer
.getExtension(FilteringExtension)
.removeListener(ViewerEvent.FilteringStateSet, updateFilteringState)
})
return {
worldTree: computed(() => worldTree.value),
availableFilters: computed(() => availableFilters.value),
filteringState: computed(() => filteringState.value),
views: computed(() => views.value)
}
}
/**
* Setup actual viewer instance & related data
*/
function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
const {
public: { viewerDebug }
} = useRuntimeConfig()
const sessionId = computed(() => nanoid())
const isInitialized = ref(false)
const { instance, initPromise, container } = useScopedState(
GlobalViewerDataKey,
createViewerDataBuilder({ viewerDebug })
) || { initPromise: Promise.resolve() }
initPromise.then(() => (isInitialized.value = true))
const hasDoneInitialLoad = ref(false)
return {
projectId: params.projectId,
sessionId,
viewer: import.meta.server
? ({
instance: undefined,
container: undefined,
init: {
promise: new Promise(() => {}),
ref: computed(() => false)
},
metadata: {
worldTree: computed(() => undefined),
availableFilters: computed(() => undefined),
views: computed(() => []),
filteringState: computed(() => undefined)
},
hasDoneInitialLoad
} as unknown as InitialSetupState['viewer'])
: {
instance,
container,
init: {
promise: initPromise,
ref: computed(() => isInitialized.value)
},
metadata: setupViewerMetadata({ viewer: instance }),
hasDoneInitialLoad
},
urlHashState: setupUrlHashState()
}
}
/**
* Setup resource requests (tied to URL resource identifier param)
*/
function setupResourceRequest(state: InitialSetupState): InitialStateWithRequest {
const route = useRoute()
const router = useRouter()
const getParam = computed(() => route.params.modelId as string)
const resources = writableAsyncComputed({
get: () => SpeckleViewer.ViewerRoute.parseUrlParameters(getParam.value),
set: async (newResources) => {
const modelId =
SpeckleViewer.ViewerRoute.createGetParamFromResources(newResources)
await router.push({
params: { modelId },
query: route.query,
hash: route.hash
})
},
initialState: [],
asyncRead: false
})
// we could use getParam, but `createGetParamFromResources` does sorting and de-duplication AFAIK
const resourceIdString = writableAsyncComputed({
get: () => SpeckleViewer.ViewerRoute.createGetParamFromResources(resources.value),
set: async (newVal) => {
const newResources = SpeckleViewer.ViewerRoute.parseUrlParameters(newVal)
await resources.update(newResources)
},
initialState: '',
asyncRead: false
})
const discussionLoadedVersionOnly = useSynchronizedCookie<boolean>(
'discussionLoadedVersionOnly',
{
default: () => true
}
)
const threadFilters = ref({ loadedVersionsOnly: discussionLoadedVersionOnly.value })
const switchModelToVersion = async (modelId: string, versionId?: string) => {
const resourceArr = resources.value.slice()
const resourceIdx = resourceArr.findIndex(
(r) => SpeckleViewer.ViewerRoute.isModelResource(r) && r.modelId === modelId
)
if (resourceIdx !== -1) {
// Replace
const newResources = resources.value.slice()
newResources.splice(
resourceIdx,
1,
new SpeckleViewer.ViewerRoute.ViewerModelResource(modelId, versionId)
)
await resources.update(newResources)
} else {
// Add new one and allow de-duplication to do its thing
await resources.update([
new SpeckleViewer.ViewerRoute.ViewerModelResource(modelId, versionId),
...resources.value
])
}
}
watch(
() => threadFilters.value.loadedVersionsOnly,
(newVal, oldVal) => {
if (newVal !== oldVal && newVal !== discussionLoadedVersionOnly.value) {
discussionLoadedVersionOnly.value = newVal
}
}
)
return {
...state,
resources: {
request: {
items: resources,
resourceIdString,
threadFilters,
switchModelToVersion
}
}
}
}
/**
* Parse URL resource request and figure out the actual resource items we need to load in the viewer
* through the GQL API
*/
function setupResponseResourceItems(
state: InitialStateWithRequest
): Pick<
InjectableViewerState['resources']['response'],
'resourceItems' | 'resourceItemsQueryVariables' | 'resourceItemsLoaded'
> {
const globalError = useError()
const {
projectId,
resources: {
request: { resourceIdString }
}
} = state
const initLoadDone = ref(import.meta.server ? false : true)
const {
result: resolvedResourcesResult,
variables: resourceItemsQueryVariables,
onError,
onResult
} = useQuery(
projectViewerResourcesQuery,
() => ({
projectId: projectId.value,
resourceUrlString: resourceIdString.value
}),
{ keepPreviousResult: true }
)
onError((err) => {
globalError.value = createError({
statusCode: 500,
message: `Viewer resource resolution failed: ${err}`
})
initLoadDone.value = true
})
onResult(() => {
initLoadDone.value = true
})
const resolvedResourceGroups = computed(
() => resolvedResourcesResult.value?.project?.viewerResources || []
)
/**
* Validated & de-duplicated resources that should be loaded in the viewer
*/
const resourceItems = computed(() => {
/**
* Flatten results into an array of items that are properly ordered according to resource identifier priority.
* Loading priority: Model w/ version > Model > Folder name > Object ID
*/
const versionItems: ViewerResourceItem[] = []
const modelItems: ViewerResourceItem[] = []
const folderItems: ViewerResourceItem[] = []
const objectItems: ViewerResourceItem[] = []
const allModelItems: ViewerResourceItem[] = []
for (const group of resolvedResourceGroups.value) {
const [resource] = SpeckleViewer.ViewerRoute.parseUrlParameters(group.identifier)
for (const item of group.items) {
if (SpeckleViewer.ViewerRoute.isModelResource(resource)) {
if (resource.versionId) {
versionItems.push(item)
} else {
modelItems.push(item)
}
} else if (SpeckleViewer.ViewerRoute.isAllModelsResource(resource)) {
allModelItems.push(item)
} else if (SpeckleViewer.ViewerRoute.isModelFolderResource(resource)) {
folderItems.push(item)
} else if (SpeckleViewer.ViewerRoute.isObjectResource(resource)) {
objectItems.push(item)
}
}
}
const orderedItems = [
...versionItems,
...modelItems,
...folderItems,
...allModelItems,
...objectItems
]
// Get rid of duplicates - only 1 resource per objectId
const encounteredModels = new Set<string>()
const encounteredObjects = new Set<string>()
const finalItems: ViewerResourceItem[] = []
for (const item of orderedItems) {
const modelId = item.modelId
const objectId = item.objectId
// Uncommenting the following line resolved model duplication issues in the Model Panel
// without affecting diffing functionality. If future diffing problems arise, revisit this.
if (modelId && encounteredModels.has(modelId)) continue
if (encounteredObjects.has(objectId)) continue
finalItems.push(item)
if (modelId) encounteredModels.add(modelId)
encounteredObjects.add(objectId)
}
return finalItems
})
const resourceItemsLoaded = computed(() => initLoadDone.value)
return {
resourceItems,
resourceItemsQueryVariables: computed(() => resourceItemsQueryVariables.value),
resourceItemsLoaded
}
}
function setupResponseResourceData(
state: InitialStateWithRequest,
resourceItemsData: ReturnType<typeof setupResponseResourceItems>
): Omit<
InjectableViewerState['resources']['response'],
'resourceItems' | 'resourceItemsQueryVariables' | 'resourceItemsLoaded'
> {
const apollo = useApolloClient().client
const globalError = useError()
const { triggerNotification } = useGlobalToast()
const logger = useLogger()
const {
projectId,
resources: {
request: { resourceIdString, threadFilters }
},
urlHashState: { diff }
} = state
const { resourceItems, resourceItemsLoaded } = resourceItemsData
const initLoadDone = ref(import.meta.server ? false : true)
const objects = computed(() =>
resourceItems.value.filter((i) => !i.modelId && !i.versionId)
)
const nonObjectResourceItems = computed(() =>
resourceItems.value.filter(
(r): r is ViewerResourceItem & { modelId: string; versionId: string } =>
!!r.modelId
)
)
const diffVersionIds = computed(() =>
flatten(
(diff.value?.diffs || []).map((d) => [d.versionA.versionId, d.versionB.versionId])
)
)
// model.loadedVersion will be the actually currently loaded version +
// any diff versions, if they're requested. the naming is confusing, but
// model.loadedVersion = all currently loaded versions of that model, altho there's usually only 1
const versionIds = computed(() =>
[
...nonObjectResourceItems.value.map((r) => r.versionId),
...diffVersionIds.value
].sort()
)
const versionCursors = ref({} as Record<string, Nullable<string>>)
const viewerLoadedResourcesVariablesFunc =
(): ViewerLoadedResourcesQueryVariables => ({
projectId: projectId.value,
modelIds: nonObjectResourceItems.value.map((r) => r.modelId).sort(),
versionIds: versionIds.value
})
// MODELS AND VERSIONS
// sorting variables so that we don't refetech just because the order changed
const {
result: viewerLoadedResourcesResult,
loading: viewerLoadedResourcesLoading,
variables: viewerLoadedResourcesVariables,
onError: onViewerLoadedResourcesError,
onResult: onViewerLoadedResourcesResult,
load: loadViewerLoadedResources
} = useLazyQuery(viewerLoadedResourcesQuery, viewerLoadedResourcesVariablesFunc, {
keepPreviousResult: true
})
const serverResourcesLoadedPromise = buildManualPromise<void>()
if (import.meta.server) {
watch(
() => resourceItemsLoaded.value,
async (newVal, oldVal) => {
if (!newVal || oldVal) return
// Load only now - once the previous query is done
await loadViewerLoadedResources()
serverResourcesLoadedPromise.resolve()
},
{ flush: 'sync' }
)
} else {
loadViewerLoadedResources()
serverResourcesLoadedPromise.resolve()
}
const project = computed(() => viewerLoadedResourcesResult.value?.project)
const models = computed(() => project.value?.models?.items || [])
const modelsAndVersionIds = computed(() =>
nonObjectResourceItems.value
.map((r) => ({
versionId: r.versionId,
model: models.value.find((m) => m.id === r.modelId)
}))
.filter((o): o is SetNonNullable<typeof o, 'model'> => !!(o.versionId && o.model))
)
const availableModelsAndVersions = computed(() => {
const modelItems = models.value
return reduce(
modelItems,
(res, entry) => {
res.push({
model: entry,
versions: [...entry.loadedVersion.items, ...entry.versions.items]
})
return res
},
[] as Array<{
model: (typeof modelItems)[0]
versions: (typeof modelItems)[0]['versions']['items']
}>
)
})
onViewerLoadedResourcesError((err) => {
// Show full page error only if serious error (core data couldn't be loaded)
const isWorkingLoad = !!viewerLoadedResourcesResult.value?.project.models.items
if (isWorkingLoad) {
return
}
globalError.value = createError({
statusCode: 500,
message: `Viewer loaded resource resolution failed: ${err}`
})
initLoadDone.value = true
})
// Load initial batch of cursors for each model
onViewerLoadedResourcesResult((res) => {
initLoadDone.value = true
if (!res.data?.project?.models) return
for (const model of res.data.project.models.items) {
const modelId = model.id
if (versionCursors.value[modelId]) continue
const cursor = model.versions.cursor
if (!cursor) continue
versionCursors.value[modelId] = cursor
}
})
const loadMoreVersions = async (modelId: string) => {
const cursor = versionCursors.value[modelId]
const baseVariables = viewerLoadedResourcesVariablesFunc()
const { data, errors } = await apollo
.query({
query: viewerModelVersionsQuery,
variables: {
projectId: baseVariables.projectId,
modelId,
versionsCursor: cursor
},
fetchPolicy: 'network-only'
})
.catch(convertThrowIntoFetchResult)
if (!data?.project?.model?.versions) {
triggerNotification({
type: ToastNotificationType.Danger,
title: "Can't load more versions",
description: getFirstErrorMessage(errors)
})
return
}
if (data.project.model.versions.cursor) {
versionCursors.value[modelId] = data.project.model.versions.cursor
}
}
// COMMENT THREADS
const {
result: viewerLoadedThreadsResult,
onError: onViewerLoadedThreadsError,
variables: threadsQueryVariables
} = useQuery(
viewerLoadedThreadsQuery,
() => ({
projectId: projectId.value,
filter: {
...threadFilters.value,
resourceIdString: resourceIdString.value
}
}),
{ keepPreviousResult: true }
)
const commentThreadsMetadata = computed(
() => viewerLoadedThreadsResult.value?.project?.commentThreads
)
const commentThreads = computed(() => commentThreadsMetadata.value?.items || [])
onViewerLoadedThreadsError((err) => {
// Show full page error only if serious error (core data couldn't be loaded)
const isWorkingLoad =
!!viewerLoadedThreadsResult.value?.project.commentThreads.items
if (isWorkingLoad) {
return
}
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Comment loading failed',
description: `${err.message}`
})
logger.error(err)
})
onServerPrefetch(async () => {
await Promise.all([serverResourcesLoadedPromise.promise])
})
return {
objects,
commentThreads,
commentThreadsMetadata,
modelsAndVersionIds,
availableModelsAndVersions,
project,
resourceQueryVariables: computed(() => viewerLoadedResourcesVariables.value),
threadsQueryVariables: computed(() => threadsQueryVariables.value),
loadMoreVersions,
resourcesLoaded: computed(() => initLoadDone.value),
resourcesLoading: computed(() => viewerLoadedResourcesLoading.value)
}
}
/**
* Load resource responses (all of the relevant data from server)
*/
function setupResourceResponse(
state: InitialStateWithRequest
): InitialStateWithRequestAndResponse {
const resourceItemsData = setupResponseResourceItems(state)
const loadedResourceData = setupResponseResourceData(state, resourceItemsData)
return {
...state,
resources: {
request: {
...state.resources.request
},
response: {
...resourceItemsData,
...loadedResourceData
}
}
}
}
function setupInterfaceState(
state: InitialStateWithUrlHashState
): InitialStateWithInterface {
// Is viewer busy - Using writable computed so that we can always intercept these calls
const isViewerBusy = ref(false)
const viewerBusy = computed({
get: () => isViewerBusy.value,
set: (newVal) => (isViewerBusy.value = !!newVal)
})
const loadProgress = ref(0)
const isolatedObjectIds = ref([] as string[])
const hiddenObjectIds = ref([] as string[])
const selectedObjects = shallowRef<Raw<SpeckleObject>[]>([])
const propertyFilter = ref(null as Nullable<PropertyInfo>)
const isPropertyFilterApplied = ref(false)
const hasAnyFiltersApplied = computed(() => {
if (isolatedObjectIds.value.length) return true
if (hiddenObjectIds.value.length) return true
if (propertyFilter.value || isPropertyFilterApplied.value) return true
if (explodeFactor.value !== 0) return true
return false
})
const viewMode = ref<ViewMode>(ViewMode.DEFAULT)
const highlightedObjectIds = ref([] as string[])
const spotlightUserSessionId = ref(null as Nullable<string>)
const lightConfig = ref(DefaultLightConfiguration)
const explodeFactor = ref(0)
const selection = ref(null as Nullable<Vector3>)
const selectedObjectIds = computed(
() =>
new Set(
selectedObjects.value
.map((o) => o.id as MaybeNullOrUndefined<string>)
.filter(isNonNullable)
)
)
/**
* THREADS
*/
const { commentThreads, openThread, newThreadEditor } = setupViewerCommentBubbles({
state
})
const isTyping = ref(false)
const hideBubbles = ref(false)
/**
* Diffing
*/
const diffState = setupUiDiffState(state)
const position = ref(new Vector3())
const target = ref(new Vector3())
const isOrthoProjection = ref(false as boolean)
return {
...state,
ui: {
diff: {
...diffState
},
selection,
lightConfig,
explodeFactor,
spotlightUserSessionId,
viewerBusy,
loadProgress,
threads: {
items: commentThreads,
openThread: {
thread: openThread,
isTyping,
newThreadEditor
},
hideBubbles
},
camera: {
// position: wrapRefWithTracking(position, 'position'),
// target: wrapRefWithTracking(target, 'target'),
position,
target,
isOrthoProjection
},
viewMode,
sectionBox: ref(null as Nullable<SectionBoxData>),
sectionBoxContext: {
visible: ref(false),
edited: ref(false)
},
filters: {
isolatedObjectIds,
hiddenObjectIds,
selectedObjects,
selectedObjectIds,
propertyFilter: {
filter: propertyFilter,
isApplied: isPropertyFilterApplied
},
hasAnyFiltersApplied
},
highlightedObjectIds,
measurement: {
enabled: ref(false),
options: ref<MeasurementOptions>({
visible: true,
type: MeasurementType.POINTTOPOINT,
units: 'm',
vertexSnap: true,
precision: 2
})
}
}
}
}
type UseSetupViewerParams = { projectId: AsyncWritableComputedRef<string> }
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 stateWithResources = setupResourceResponse(initialStateWithRequest)
const state: InjectableViewerState = setupInterfaceState(stateWithResources)
// 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)
// Inject it into descendant components
provide(InjectableViewerStateKey, rawState)
return rawState
}
/**
* COMPOSABLES FOR RETRIEVING (PARTS OF) INJECTABLE STATE
*/
export function useInjectedViewerState(): InjectableViewerState {
// 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(InjectableViewerStateKey) as InjectableViewerState
return state
}
export function useInjectedViewer(): InjectableViewerState['viewer'] {
const { viewer } = useInjectedViewerState()
return viewer
}
export function useInjectedViewerLoadedResources(): InjectableViewerState['resources']['response'] {
const { resources } = useInjectedViewerState()
return resources.response
}
export function useInjectedViewerRequestedResources(): InjectableViewerState['resources']['request'] {
const { resources } = useInjectedViewerState()
return resources.request
}
export function useInjectedViewerInterfaceState(): InjectableViewerState['ui'] {
const { ui } = useInjectedViewerState()
return ui
}
export function useResetUiState() {
const {
ui: { camera, sectionBox, highlightedObjectIds, lightConfig, viewMode }
} = useInjectedViewerState()
const { resetFilters } = useFilterUtilities()
const { endDiff } = useDiffUtilities()
return () => {
camera.isOrthoProjection.value = false
sectionBox.value = null
highlightedObjectIds.value = []
lightConfig.value = { ...DefaultLightConfiguration }
viewMode.value = ViewMode.DEFAULT
resetFilters()
endDiff()
}
}
export { InjectableViewerStateKey, useSetupViewerScope }