83d8035dc2
* root + server * frontend * frontend-2 * dui3 * dui3 * tailwind theme * ui-components * preview service * viewer * viewer-sandbox * fileimport-service * webhook service * objectloader * shared * ui-components-nuxt * WIP full config * WIP full linter * eslint projectwide util * minor fix * removing redundant ci * clean up test errors * fixed prettier formatting * CI improvements * TSC lint fix * 'buildBatch' needs to be async since some batch types (like Text) require it. Removed a disabled liniting rule from ObjLoader * removed unnecessary void --------- Co-authored-by: AlexandruPopovici <alexandrupopoviciioan@gmail.com>
393 lines
10 KiB
TypeScript
393 lines
10 KiB
TypeScript
import type { CSSProperties, Ref } from 'vue'
|
|
import { SpeckleViewer } from '@speckle/shared'
|
|
import type { Nullable } from '@speckle/shared'
|
|
import {
|
|
useInjectedViewerInterfaceState,
|
|
useInjectedViewerState,
|
|
useResetUiState
|
|
} from '~~/lib/viewer/composables/setup'
|
|
import type { LoadedCommentThread } from '~~/lib/viewer/composables/setup'
|
|
import { graphql } from '~~/lib/common/generated/gql'
|
|
import { debounce } from 'lodash-es'
|
|
import { Vector3 } from 'three'
|
|
import {
|
|
useOnViewerLoadComplete,
|
|
useSelectionEvents,
|
|
useViewerCameraTracker
|
|
} from '~~/lib/viewer/composables/viewer'
|
|
import {
|
|
useGetScreenCenterObjectId,
|
|
useViewerAnchoredPoints
|
|
} from '~~/lib/viewer/composables/anchorPoints'
|
|
import {
|
|
HorizontalDirection,
|
|
useOnBeforeWindowUnload,
|
|
useResponsiveHorizontalDirectionCalculation
|
|
} from '~~/lib/common/composables/window'
|
|
import { useViewerUserActivityBroadcasting } from '~~/lib/viewer/composables/activity'
|
|
import { useIntervalFn } from '@vueuse/core'
|
|
import {
|
|
StateApplyMode,
|
|
useApplySerializedState,
|
|
useStateSerialization
|
|
} from '~~/lib/viewer/composables/serialization'
|
|
import type { Merge } from 'type-fest'
|
|
import { useSelectionUtilities } from '~~/lib/viewer/composables/ui'
|
|
|
|
graphql(`
|
|
fragment ViewerCommentBubblesData on Comment {
|
|
id
|
|
viewedAt
|
|
viewerState
|
|
}
|
|
`)
|
|
|
|
export type ViewerNewThreadBubbleModel = {
|
|
isExpanded: boolean
|
|
isVisible: boolean
|
|
isOccluded: boolean
|
|
style: Partial<CSSProperties>
|
|
clickLocation: Nullable<Vector3>
|
|
}
|
|
|
|
export function useViewerNewThreadBubble(params: {
|
|
parentEl: Ref<Nullable<HTMLElement>>
|
|
block?: Ref<boolean>
|
|
}) {
|
|
const { parentEl, block } = params
|
|
|
|
const {
|
|
threads: {
|
|
openThread: { newThreadEditor }
|
|
},
|
|
camera: { target },
|
|
selection
|
|
} = useInjectedViewerInterfaceState()
|
|
const getCamCenterObjId = useGetScreenCenterObjectId()
|
|
const { setSelectionFromObjectIds } = useSelectionUtilities()
|
|
const logger = useLogger()
|
|
|
|
const buttonState = ref({
|
|
isExpanded: false,
|
|
isVisible: false,
|
|
isOccluded: false,
|
|
clickLocation: null,
|
|
style: {}
|
|
} as ViewerNewThreadBubbleModel)
|
|
|
|
const { updatePositions } = useViewerAnchoredPoints({
|
|
parentEl,
|
|
points: computed(() => buttonState.value),
|
|
pointLocationGetter: (b) => b.clickLocation,
|
|
updatePositionCallback: (state, result) => {
|
|
state.isOccluded = result.isOccluded
|
|
state.style = {
|
|
...state.style,
|
|
...result.style,
|
|
opacity: state.isOccluded ? '0.8' : '1.0'
|
|
}
|
|
}
|
|
})
|
|
|
|
const closeNewThread = () => {
|
|
buttonState.value.isExpanded = false
|
|
buttonState.value.isVisible = false
|
|
buttonState.value.clickLocation = null
|
|
}
|
|
|
|
useSelectionEvents({
|
|
singleClickCallback: (_event, { firstVisibleSelectionHit }) => {
|
|
if (block?.value) return
|
|
|
|
buttonState.value.isExpanded = false
|
|
if (!firstVisibleSelectionHit) {
|
|
closeNewThread()
|
|
return
|
|
}
|
|
|
|
buttonState.value.clickLocation = firstVisibleSelectionHit.point.clone()
|
|
buttonState.value.isVisible = true
|
|
updatePositions()
|
|
}
|
|
})
|
|
|
|
if (block) {
|
|
watch(block, (isBlocked) => {
|
|
if (!isBlocked) return
|
|
closeNewThread()
|
|
})
|
|
}
|
|
|
|
watch(
|
|
() => buttonState.value.isExpanded,
|
|
(newVal) => {
|
|
newThreadEditor.value = newVal
|
|
}
|
|
)
|
|
|
|
watch(newThreadEditor, (isNewThread, oldIsNewThread) => {
|
|
if (isNewThread && !!isNewThread !== !!oldIsNewThread) {
|
|
if (!buttonState.value.clickLocation && !target.value && !selection.value) {
|
|
logger.warn('Unable to enable new thread editor due to missing position data')
|
|
newThreadEditor.value = false
|
|
return
|
|
}
|
|
|
|
// Set "new thread bubble" location & enable it
|
|
if (!buttonState.value.clickLocation) {
|
|
if (target.value) {
|
|
buttonState.value.clickLocation = target.value.clone()
|
|
} else if (selection.value) {
|
|
buttonState.value.clickLocation = selection.value.clone()
|
|
}
|
|
}
|
|
|
|
buttonState.value.isExpanded = true
|
|
buttonState.value.isVisible = true
|
|
updatePositions()
|
|
|
|
// Also invoke selection, if needed
|
|
if (selection.value) return
|
|
|
|
const oid = getCamCenterObjId()
|
|
if (!oid) return
|
|
setSelectionFromObjectIds([oid])
|
|
}
|
|
})
|
|
|
|
return { buttonState, closeNewThread }
|
|
}
|
|
|
|
export type CommentBubbleModel = Merge<
|
|
LoadedCommentThread,
|
|
{ viewerState: Nullable<SpeckleViewer.ViewerState.SerializedViewerState> }
|
|
> & {
|
|
isOccluded: boolean
|
|
style: Partial<CSSProperties> & { x?: number; y?: number }
|
|
}
|
|
|
|
export function useViewerCommentBubblesProjection(params: {
|
|
parentEl: Ref<Nullable<HTMLElement>>
|
|
}) {
|
|
const { parentEl } = params
|
|
const {
|
|
ui: {
|
|
threads: { items: commentThreads }
|
|
}
|
|
} = useInjectedViewerState()
|
|
|
|
useViewerAnchoredPoints({
|
|
parentEl,
|
|
points: computed(() => Object.values(commentThreads.value)),
|
|
pointLocationGetter: (t) => {
|
|
const state = t.viewerState
|
|
|
|
const selection = state?.ui.selection
|
|
if (selection?.length) {
|
|
return new Vector3(selection[0], selection[1], selection[2])
|
|
}
|
|
|
|
const target = state?.ui.camera.target
|
|
if (target?.length) {
|
|
return new Vector3(target[0], target[1], target[2])
|
|
}
|
|
|
|
return undefined
|
|
},
|
|
updatePositionCallback: (thread, result) => {
|
|
thread.isOccluded = result.isOccluded
|
|
thread.style = {
|
|
...thread.style,
|
|
...result.style,
|
|
opacity: thread.isOccluded ? '0.5' : '1.0',
|
|
transition: 'all 100ms ease'
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
export function useViewerOpenedThreadUpdateEmitter() {
|
|
if (import.meta.server) return
|
|
|
|
const {
|
|
urlHashState: { focusedThreadId }
|
|
} = useInjectedViewerState()
|
|
const { emitViewing } = useViewerUserActivityBroadcasting()
|
|
|
|
watch(focusedThreadId, (id, oldId) => {
|
|
if (id !== oldId) {
|
|
emitViewing()
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Set up auto-focusing on opened thread and setting/unsetting viewer state
|
|
*/
|
|
export function useViewerThreadTracking() {
|
|
if (import.meta.server) return
|
|
|
|
const applyState = useApplySerializedState()
|
|
const { serialize: serializeState } = useStateSerialization()
|
|
const resetState = useResetUiState()
|
|
|
|
const state = useInjectedViewerState()
|
|
const {
|
|
ui: {
|
|
threads: { openThread },
|
|
camera: { position, target }
|
|
}
|
|
} = state
|
|
|
|
const oldState = ref(
|
|
null as Nullable<SpeckleViewer.ViewerState.SerializedViewerState>
|
|
)
|
|
|
|
const refocus = async (
|
|
commentState: SpeckleViewer.ViewerState.SerializedViewerState
|
|
) => {
|
|
await applyState(commentState, StateApplyMode.ThreadOpen)
|
|
}
|
|
|
|
// Do this once viewer loads things
|
|
useOnViewerLoadComplete(({ isInitial }) => {
|
|
const viewerState = openThread.thread.value?.viewerState
|
|
if (SpeckleViewer.ViewerState.isSerializedViewerState(viewerState)) {
|
|
refocus(viewerState)
|
|
}
|
|
|
|
// On initial - rewrite old state coords cause they're not valid before initial load
|
|
if (isInitial) {
|
|
const old = oldState.value || serializeState()
|
|
oldState.value = {
|
|
...old,
|
|
ui: {
|
|
...old.ui,
|
|
camera: {
|
|
...old.ui.camera,
|
|
position: position.value.toArray(),
|
|
target: target.value.toArray()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
// Also do this when openThread changes
|
|
watch(openThread.thread, async (newThread, oldThread) => {
|
|
if (newThread?.id !== oldThread?.id) {
|
|
const newState = newThread?.viewerState
|
|
if (newState && SpeckleViewer.ViewerState.isSerializedViewerState(newState)) {
|
|
await refocus(newState)
|
|
} else {
|
|
resetState()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Responsively switches a comment thread/new-thread container from left side to right side of the button
|
|
* (or vice versa) depending on how much space there is on each side
|
|
*/
|
|
export function useExpandedThreadResponsiveLocation(params: {
|
|
threadContainer: Ref<Nullable<HTMLElement>>
|
|
width: number
|
|
stopUpdatesBelowWidth?: number
|
|
position?: { x: number; y: number }
|
|
}) {
|
|
const { threadContainer, width } = params
|
|
const stopUpdatesBelowWidth = params.stopUpdatesBelowWidth || width * 2
|
|
|
|
const margin = 12
|
|
const leftForShowingOnRightSide = `calc(100% + ${margin}px)`
|
|
const leftForShowingOnLeftSide = `calc(-${width + margin}px)`
|
|
|
|
const { direction, recalculateDirection } =
|
|
useResponsiveHorizontalDirectionCalculation({
|
|
el: threadContainer,
|
|
defaultDirection: HorizontalDirection.Right,
|
|
stopUpdatesBelowWidth
|
|
})
|
|
|
|
const style = computed(() => ({
|
|
top: '50%',
|
|
left:
|
|
direction.value === HorizontalDirection.Right
|
|
? leftForShowingOnRightSide
|
|
: leftForShowingOnLeftSide,
|
|
transformOrigin: 'center center',
|
|
transform: 'translateY(-50%)',
|
|
width: `${width}px`
|
|
}))
|
|
|
|
useViewerCameraTracker(() => recalculateDirection())
|
|
|
|
return {
|
|
style,
|
|
recalculateStyle: recalculateDirection
|
|
}
|
|
}
|
|
|
|
export function useIsTypingUpdateEmitter() {
|
|
const {
|
|
ui: {
|
|
threads: {
|
|
openThread: { isTyping }
|
|
}
|
|
}
|
|
} = useInjectedViewerState()
|
|
const { emitViewing } = useViewerUserActivityBroadcasting()
|
|
|
|
const debouncedMarkNoLongerTyping = debounce(
|
|
() => automaticUpdateIsTyping(false),
|
|
7000
|
|
)
|
|
const pauseAutomaticUpdates = ref(false)
|
|
|
|
const automaticUpdateIsTyping = (newVal: boolean) => {
|
|
if (pauseAutomaticUpdates.value) return
|
|
updateIsTyping(newVal)
|
|
}
|
|
|
|
const updateIsTyping = (newVal: boolean) => {
|
|
if (newVal === isTyping.value) return
|
|
isTyping.value = newVal
|
|
}
|
|
|
|
const onKeyDownHandler = () => {
|
|
if (!isTyping.value) {
|
|
automaticUpdateIsTyping(true)
|
|
}
|
|
debouncedMarkNoLongerTyping()
|
|
}
|
|
|
|
watch(isTyping, (newVal, oldVal) => {
|
|
if (!!newVal === !!oldVal) return
|
|
emitViewing()
|
|
})
|
|
onBeforeUnmount(() => updateIsTyping(false))
|
|
useOnBeforeWindowUnload(() => updateIsTyping(false))
|
|
|
|
return {
|
|
onKeyDownHandler,
|
|
updateIsTyping,
|
|
pauseAutomaticUpdates
|
|
}
|
|
}
|
|
|
|
export function useAnimatingEllipsis() {
|
|
const baseValue = '.'
|
|
const value = ref(baseValue)
|
|
|
|
const { pause, resume } = useIntervalFn(() => {
|
|
if (value.value.length < 3) {
|
|
value.value = value.value + baseValue
|
|
} else {
|
|
value.value = baseValue
|
|
}
|
|
}, 250)
|
|
|
|
return { ellipsis: value, controls: { pause, resume } }
|
|
}
|