Files
speckle-server/packages/frontend-2/lib/viewer/composables/viewer.ts
T
Kristaps Fabians Geikins 9427686d42 fix(fe2): various follow mode & thread viewer state sync fixes & improvements (#1595)
* fix(fe2): unfollow on camera move

* WIP new state hydration function

* WIP sync state

* minor cleanup

* fix coloring not being tracked

* fix for post thread close camera pos restore

* supporting duplicate users

* preventing guest commenting + state reset fixes

* fixed guests not receiving viewer comment updates

* post-thread creation opens new thread

* removing gap between 'X is typing' and bubble appearing

* reset filters will also reset colors now

* fixed thread full context

* camera reset fix

* thread reset fix

* fixed router concurrency issues

* followed user avatar fix

* TONS OF DEBUGGING FOR ROUTER QUEUING

* removing queued routing debugging stuff + disabling spotlight cancelation

* WIP async URL updates

* missing authLogger fixed

* fix for broken projection

* fix for bubbles positions not updating correctly

* queued routing cleanup

* fixed spotlight mode disabling unnecessarily

* added back stoplight stop on ctrl

* undid spotlight debugging
2023-05-29 15:20:32 +03:00

318 lines
8.4 KiB
TypeScript

import {
InitialStateWithRequestAndResponse,
InjectableViewerState,
useInjectedViewerState
} from '~~/lib/viewer/composables/setup'
import { SelectionEvent, ViewerEvent } from '@speckle/viewer'
import { debounce, isArray, throttle } from 'lodash-es'
import { MaybeAsync, Nullable, TimeoutError, timeoutAt } from '@speckle/shared'
import { until } from '@vueuse/shared'
import { Vector3 } from 'three'
import { areVectorsLooselyEqual } from '~~/lib/viewer/helpers/three'
function getFirstVisibleSelectionHit(
{ hits }: SelectionEvent,
state: Pick<InjectableViewerState, 'viewer'>
) {
const {
viewer: {
metadata: { filteringState }
}
} = state
const hasHiddenObjects = (filteringState.value?.hiddenObjects || []).length !== 0
const hasIsolatedObjects =
!!filteringState.value?.isolatedObjects &&
filteringState.value?.isolatedObjects.length !== 0
for (const hit of hits) {
if (hasHiddenObjects) {
if (!filteringState.value?.hiddenObjects?.includes(hit.object.id as string)) {
return hit
}
} else if (hasIsolatedObjects) {
if (filteringState.value.isolatedObjects?.includes(hit.object.id as string))
return hit
} else {
return hit
}
}
return null
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useViewerEventListener<A = any>(
name: ViewerEvent | ViewerEvent[],
listener: (...args: A[]) => MaybeAsync<void>,
options?: Partial<{
state: InitialStateWithRequestAndResponse
}>
) {
const {
viewer: { instance }
} = options?.state || useInjectedViewerState()
const names = isArray(name) ? name : [name]
const unmount = () => {
for (const n of names) {
instance.removeListener(n, listener)
}
}
onMounted(() => {
for (const n of names) {
instance.on(n, listener)
}
})
onBeforeUnmount(() => {
unmount
})
return unmount
}
export function useViewerCameraTracker(
callback: () => void,
options?: Partial<{
throttleWait: number
debounceWait: number
onlyInvokeOnMeaningfulChanges: boolean
}>
): void {
const {
viewer: { instance }
} = useInjectedViewerState()
const {
throttleWait = 50,
debounceWait,
onlyInvokeOnMeaningfulChanges
} = options || {}
const lastPos = ref(null as Nullable<Vector3>)
const lastTarget = ref(null as Nullable<Vector3>)
const callbackChangeTrackerWrapper = () => {
if (!onlyInvokeOnMeaningfulChanges) {
return callback()
}
// Only invoke callback if position/target changed in a meaningful way
const activeCam = instance.cameraHandler.activeCam
const controls = activeCam.controls
const viewerPos = new Vector3()
const viewerTarget = new Vector3()
controls.getPosition(viewerPos)
controls.getTarget(viewerTarget)
let meaningfulChangeFound = false
if (!lastPos.value || !areVectorsLooselyEqual(lastPos.value, viewerPos)) {
meaningfulChangeFound = true
}
if (!lastTarget.value || !areVectorsLooselyEqual(lastTarget.value, viewerTarget)) {
meaningfulChangeFound = true
}
if (meaningfulChangeFound) {
lastPos.value = viewerPos.clone()
lastTarget.value = viewerTarget.clone()
callback()
}
}
const finalCallback = debounceWait
? debounce(callbackChangeTrackerWrapper, debounceWait)
: throttleWait
? throttle(callbackChangeTrackerWrapper, throttleWait)
: callbackChangeTrackerWrapper
onMounted(() => {
instance.cameraHandler.controls.addEventListener('update', finalCallback)
})
onBeforeUnmount(() => {
instance.cameraHandler.controls.removeEventListener('update', finalCallback)
})
}
export function useViewerCameraControlStartTracker(callback: () => void) {
const {
viewer: { instance }
} = useInjectedViewerState()
const removeListener = () =>
instance.cameraHandler.controls.removeEventListener('controlstart', callback)
onMounted(() => {
instance.cameraHandler.controls.addEventListener('controlstart', callback)
})
onBeforeUnmount(() => {
removeListener()
})
return removeListener
}
export function useViewerCameraRestTracker(
callback: () => void,
options?: Partial<{ debounceWait: number }>
) {
const {
viewer: { instance }
} = useInjectedViewerState()
const { debounceWait = 200 } = options || {}
const finalCallback = debounceWait ? debounce(callback, debounceWait) : callback
const removeListener = () =>
instance.cameraHandler.controls.removeEventListener('rest', finalCallback)
onMounted(() => {
instance.cameraHandler.controls.addEventListener('rest', finalCallback)
})
onBeforeUnmount(() => {
removeListener()
})
return removeListener
}
export function useViewerCameraControlEndTracker(callback: () => void) {
const {
viewer: { instance }
} = useInjectedViewerState()
const removeListener = () =>
instance.cameraHandler.controls.removeEventListener('rest', callback)
onMounted(() => {
instance.cameraHandler.controls.addEventListener('rest', callback)
})
onBeforeUnmount(() => {
removeListener()
})
return removeListener
}
export function useSelectionEvents(
params: {
singleClickCallback?: (
event: Nullable<SelectionEvent>,
extra: { firstVisibleSelectionHit: Nullable<SelectionEvent['hits'][0]> }
) => void
doubleClickCallback?: (
event: Nullable<SelectionEvent>,
extra: { firstVisibleSelectionHit: Nullable<SelectionEvent['hits'][0]> }
) => void
},
options?: Partial<{
state: InitialStateWithRequestAndResponse
debounceWait: number
}>
) {
if (process.server) return
const { singleClickCallback, doubleClickCallback } = params
const state = options?.state || useInjectedViewerState()
const {
viewer: { instance }
} = state
const { debounceWait = 50 } = options || {}
const debouncedSingleClickCallback = singleClickCallback
? debounce((event: Nullable<SelectionEvent>) => {
const firstVisibleSelectionHit = event
? getFirstVisibleSelectionHit(event, state)
: null
return singleClickCallback(event, { firstVisibleSelectionHit })
}, debounceWait)
: undefined
const debouncedDoubleClickCallback = doubleClickCallback
? debounce((event: Nullable<SelectionEvent>) => {
const firstVisibleSelectionHit = event
? getFirstVisibleSelectionHit(event, state)
: null
return doubleClickCallback(event, { firstVisibleSelectionHit })
}, debounceWait)
: undefined
onMounted(() => {
if (debouncedDoubleClickCallback) {
instance.on(ViewerEvent.ObjectDoubleClicked, debouncedDoubleClickCallback)
}
if (debouncedSingleClickCallback) {
instance.on(ViewerEvent.ObjectClicked, debouncedSingleClickCallback)
}
})
onBeforeUnmount(() => {
if (debouncedDoubleClickCallback) {
instance.removeListener(
ViewerEvent.ObjectDoubleClicked,
debouncedDoubleClickCallback
)
}
if (debouncedSingleClickCallback) {
instance.removeListener(ViewerEvent.ObjectClicked, debouncedSingleClickCallback)
}
})
}
export function useGetObjectUrl() {
const config = useRuntimeConfig()
return (projectId: string, objectId: string) =>
`${config.public.apiOrigin}/streams/${projectId}/objects/${objectId}`
}
export function useOnViewerLoadComplete(
listener: (params: { isInitial: boolean }) => MaybeAsync<void>,
options?: Partial<{
/**
* Whether to only invoke the listener once on the very first LoadComplete event. Default: false
*/
initialOnly: boolean
/**
* If true, will trigger the listener after the next isBusy=false event that comes after LoadComplete. Default: true
*/
waitForBusyOver: boolean
}>
) {
const {
ui: { viewerBusy }
} = useInjectedViewerState()
const { initialOnly, waitForBusyOver = true } = options || {}
const hasRun = ref(false)
const cancel = useViewerEventListener(ViewerEvent.LoadComplete, async () => {
if (initialOnly && hasRun.value) {
cancel()
return
}
try {
await (waitForBusyOver
? Promise.race([
until(viewerBusy).toBe(false),
timeoutAt(
1000,
'Waiting for viewer business to be over post-LoadComplete timed out'
)
])
: Promise.resolve())
} catch (e) {
if (!(e instanceof TimeoutError)) throw e
console.warn(e.message)
}
listener({ isInitial: !hasRun.value })
hasRun.value = true
if (initialOnly) cancel()
})
}