308 lines
8.8 KiB
TypeScript
308 lines
8.8 KiB
TypeScript
import type { MaybeRef } from '@vueuse/core'
|
|
import type { Nullable } from '@speckle/shared'
|
|
import { onProjectVersionsPreviewGeneratedSubscription } from '~~/lib/projects/graphql/subscriptions'
|
|
import { useSubscription } from '@vue/apollo-composable'
|
|
import { useLock } from '~~/lib/common/composables/singleton'
|
|
import PreviewPlaceholder from '~~/assets/images/preview_placeholder.png'
|
|
import { isValidBase64Image } from '@speckle/shared/images/base64'
|
|
import { nanoid } from 'nanoid'
|
|
|
|
/**
|
|
* Eager loading previews ensures a better LCP score, but also hits the preview endpoint more often.
|
|
* Since we don't know the viewport size in SSR, we can just set a limit of how many previews to eager
|
|
* load after which they should use spinners.
|
|
*
|
|
* Theoretically even 1 eager load will fix the LCP issue, but it will look odd if all of the other ones
|
|
* show up as spinners. So ideally just set enough for 1 page load.
|
|
*
|
|
* Assuming a large screen w/ the busiest preview page (project page w/ model grid), there would be
|
|
* about 20 previews
|
|
*/
|
|
const PREVIEWS_EAGER_LOAD_COUNT = 20
|
|
|
|
const previewUrlProjectIdRegexp = /\/preview\/([\w\d]+)\//i
|
|
const previewUrlCommitIdRegexp = /\/commits\/([\w\d]+)/i
|
|
const previewUrlObjectIdRegexp = /\/commits\/([\w\d]+)/i
|
|
|
|
class AngleNotFoundError extends Error {}
|
|
|
|
const usePreviewsState = () =>
|
|
useState('preview_images_load_state', () => ({
|
|
/**
|
|
* How many previews have already been eager loaded
|
|
*/
|
|
eagerLoadedKeys: new Set<string>()
|
|
}))
|
|
|
|
/**
|
|
* Get authenticated preview image URL and subscribes to preview image generation events so that the preview image URL
|
|
* is updated whenever generation finishes
|
|
*
|
|
* TODO: Refactor, the internals have gotten very messy and overly complicated
|
|
*/
|
|
export function usePreviewImageBlob(
|
|
previewUrl: MaybeRef<string | null | undefined>,
|
|
options?: Partial<{
|
|
/**
|
|
* Allows disabling the mechanism conditionally (e.g. if image not in viewport)
|
|
*/
|
|
enabled: MaybeRef<boolean>
|
|
/**
|
|
* Whether to avoid spinners and just embed the image immediately. Means we will likely
|
|
* load a lot more than needed, but also get a way better LCP score (no spinners).
|
|
*
|
|
* If enabled, overrides `enabled` to be true.
|
|
*/
|
|
eagerLoad: boolean
|
|
}>
|
|
) {
|
|
// Checking if we're allowed to eager load
|
|
const { $isAppHydrated } = useNuxtApp()
|
|
const state = usePreviewsState()
|
|
const eagerLoadKey = unref(previewUrl) || nanoid()
|
|
const eagerLoad =
|
|
options?.eagerLoad &&
|
|
!$isAppHydrated.value &&
|
|
(state.value.eagerLoadedKeys.size < PREVIEWS_EAGER_LOAD_COUNT ||
|
|
state.value.eagerLoadedKeys.has(eagerLoadKey))
|
|
|
|
if (eagerLoad) {
|
|
state.value.eagerLoadedKeys.add(eagerLoadKey)
|
|
}
|
|
|
|
// Continue on with normal operation
|
|
const { enabled = ref(true) } = options || {}
|
|
const logger = useLogger()
|
|
const lazyLoad = !eagerLoad
|
|
|
|
const url = ref<Nullable<string>>(PreviewPlaceholder)
|
|
const hasDoneFirstLoad = ref(false)
|
|
const panoramaUrl = ref(null as Nullable<string>)
|
|
const isLoadingPanorama = ref(false)
|
|
const shouldLoadPanorama = ref(false)
|
|
const basePanoramaUrl = computed(() => unref(previewUrl) + '/all')
|
|
const isEnabled = computed(() => {
|
|
if (import.meta.server) return true // always true on server
|
|
return unref(enabled)
|
|
})
|
|
const cacheBust = ref(0)
|
|
const isPanoramaPlaceholder = ref(false)
|
|
|
|
const ret = {
|
|
previewUrl: computed(() => url.value),
|
|
panoramaPreviewUrl: computed(() => panoramaUrl.value),
|
|
isLoadingPanorama,
|
|
shouldLoadPanorama,
|
|
hasDoneFirstLoad: computed(() => hasDoneFirstLoad.value),
|
|
isPanoramaPlaceholder: computed(() => isPanoramaPlaceholder.value),
|
|
wasEagerLoaded: eagerLoad
|
|
}
|
|
|
|
const previewUrlPath = computed(() => {
|
|
const basePreviewUrl = unref(previewUrl)
|
|
if (!basePreviewUrl) return null
|
|
|
|
const urlObj = new URL(basePreviewUrl)
|
|
return urlObj.pathname
|
|
})
|
|
|
|
const projectId = computed(() => {
|
|
const path = previewUrlPath.value
|
|
if (!path) return null
|
|
const [, val] = previewUrlProjectIdRegexp.exec(path) || [null, null]
|
|
return val
|
|
})
|
|
|
|
const versionId = computed(() => {
|
|
const path = previewUrlPath.value
|
|
if (!path) return null
|
|
const [, val] = previewUrlCommitIdRegexp.exec(path) || [null, null]
|
|
return val
|
|
})
|
|
|
|
const objectId = computed(() => {
|
|
const path = previewUrlPath.value
|
|
if (!path) return null
|
|
const [, val] = previewUrlObjectIdRegexp.exec(path) || [null, null]
|
|
return val
|
|
})
|
|
|
|
const isPreviewServiceUrl = computed(() => !!projectId.value)
|
|
|
|
const { hasLock } = useLock(
|
|
computed(() => `useProjectModelUpdateTracking-${unref(previewUrl) || ''}`)
|
|
)
|
|
const { onResult: onProjectPreviewGenerated } = useSubscription(
|
|
onProjectVersionsPreviewGeneratedSubscription,
|
|
() => ({
|
|
id: projectId.value || ''
|
|
}),
|
|
() => ({
|
|
enabled:
|
|
!!projectId.value && hasLock.value && isEnabled.value && !import.meta.server
|
|
})
|
|
)
|
|
|
|
onProjectPreviewGenerated((res) => {
|
|
const message = res.data?.projectVersionsPreviewGenerated
|
|
if (!message) return
|
|
|
|
let regenerate = false
|
|
if (objectId.value && objectId.value === message.objectId) {
|
|
regenerate = true
|
|
} else if (versionId.value && versionId.value === message.versionId) {
|
|
regenerate = true
|
|
}
|
|
|
|
if (regenerate) {
|
|
void regeneratePreviews()
|
|
}
|
|
})
|
|
|
|
async function processBasePreviewUrl() {
|
|
if (!isEnabled.value) return
|
|
|
|
const basePreviewUrl = unref(previewUrl)
|
|
try {
|
|
if (!basePreviewUrl) {
|
|
url.value = PreviewPlaceholder
|
|
hasDoneFirstLoad.value = true
|
|
return
|
|
}
|
|
|
|
if (isValidBase64Image(basePreviewUrl)) {
|
|
// return as is
|
|
url.value = basePreviewUrl
|
|
hasDoneFirstLoad.value = true
|
|
return
|
|
}
|
|
|
|
const blobUrlConfig = new URL(basePreviewUrl)
|
|
blobUrlConfig.searchParams.set('v', cacheBust.value.toString())
|
|
const blobUrl = blobUrlConfig.toString()
|
|
|
|
// Load img in browser first, before we set the url
|
|
if (import.meta.client && lazyLoad) {
|
|
const img = new Image()
|
|
img.src = blobUrl
|
|
await new Promise((resolve, reject) => {
|
|
img.onload = resolve
|
|
img.onerror = reject
|
|
})
|
|
}
|
|
|
|
url.value = blobUrl
|
|
} catch (e) {
|
|
logger.error('Preview image load error', e)
|
|
url.value = PreviewPlaceholder
|
|
} finally {
|
|
hasDoneFirstLoad.value = true
|
|
}
|
|
}
|
|
|
|
async function processPanoramaPreviewUrl() {
|
|
if (!isEnabled.value || import.meta.server) return
|
|
|
|
const basePreviewUrl = unref(previewUrl)
|
|
try {
|
|
isLoadingPanorama.value = true
|
|
if (!basePreviewUrl) {
|
|
url.value = PreviewPlaceholder
|
|
return
|
|
}
|
|
|
|
if (isValidBase64Image(basePreviewUrl)) {
|
|
panoramaUrl.value = null // panorama unsupported
|
|
return
|
|
}
|
|
|
|
const blobUrlConfig = new URL(basePanoramaUrl.value)
|
|
blobUrlConfig.searchParams.set('v', cacheBust.value.toString())
|
|
const blobUrl = blobUrlConfig.toString()
|
|
|
|
// Load img in browser first, before we set the url
|
|
if (import.meta.client) {
|
|
const img = new Image()
|
|
img.src = blobUrl
|
|
await new Promise((resolve, reject) => {
|
|
img.onload = resolve
|
|
img.onerror = reject
|
|
})
|
|
|
|
// If width is 700px or less, it's the placeholder not the actual panorama
|
|
isPanoramaPlaceholder.value = img.naturalWidth <= 700
|
|
}
|
|
|
|
panoramaUrl.value = blobUrl
|
|
} catch (e) {
|
|
if (!(e instanceof AngleNotFoundError)) {
|
|
logger.error('Panorama preview image load error:', e)
|
|
}
|
|
|
|
panoramaUrl.value = null
|
|
} finally {
|
|
isLoadingPanorama.value = false
|
|
}
|
|
}
|
|
|
|
const regeneratePreviews = async () => {
|
|
cacheBust.value++
|
|
await Promise.all([
|
|
processBasePreviewUrl(),
|
|
...(shouldLoadPanorama.value ? [processPanoramaPreviewUrl()] : [])
|
|
])
|
|
}
|
|
|
|
if (import.meta.client) {
|
|
watch(shouldLoadPanorama, (newVal) => {
|
|
if (newVal) processPanoramaPreviewUrl()
|
|
})
|
|
|
|
watch(
|
|
() => unref(previewUrl),
|
|
() => {
|
|
void regeneratePreviews()
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => isEnabled.value,
|
|
(newVal) => {
|
|
if (!newVal) return
|
|
|
|
void regeneratePreviews()
|
|
}
|
|
)
|
|
} else {
|
|
useHead({
|
|
link: computed(() => [
|
|
...(url.value?.length && isPreviewServiceUrl.value
|
|
? [{ rel: 'preload', as: <const>'image', href: url.value }]
|
|
: [])
|
|
])
|
|
})
|
|
}
|
|
|
|
const init = () => {
|
|
if (!eagerLoad && import.meta.server) {
|
|
return // don't do anything - show spinner
|
|
}
|
|
|
|
void regeneratePreviews()
|
|
}
|
|
init()
|
|
|
|
return ret
|
|
}
|
|
|
|
export function useCommentScreenshotImage(
|
|
screenshotData: MaybeRef<string | null | undefined>
|
|
) {
|
|
const backgroundImage = computed(() => {
|
|
const screenshot = unref(screenshotData) || 'data:null'
|
|
return `url("${screenshot}")`
|
|
})
|
|
|
|
return { backgroundImage, screenshot: unref(screenshotData) }
|
|
}
|