Files
speckle-server/packages/frontend-2/lib/viewer/composables/ui.ts
T

663 lines
17 KiB
TypeScript

import { SpeckleViewer, TIME_MS, timeoutAt } from '@speckle/shared'
import {
type TreeNode,
type MeasurementOptions,
type PropertyInfo,
ViewMode
} from '@speckle/viewer'
import { MeasurementsExtension, ViewModes } from '@speckle/viewer'
import { until } from '@vueuse/shared'
import { difference, isString, uniq } from 'lodash-es'
import { useEmbedState, useEmbed } from '~/lib/viewer/composables/setup/embed'
import type { SpeckleObject } from '~/lib/viewer/helpers/sceneExplorer'
import { isNonNullable } from '~~/lib/common/helpers/utils'
import {
useInjectedViewer,
useInjectedViewerInterfaceState,
useInjectedViewerState,
type InjectableViewerState
} from '~~/lib/viewer/composables/setup'
import { useDiffBuilderUtilities } from '~~/lib/viewer/composables/setup/diff'
import { getKeyboardShortcutTitle, onKeyboardShortcut } from '@speckle/ui-components'
import { ViewerShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts'
import type {
ViewerShortcut,
ViewerShortcutAction
} from '~/lib/viewer/helpers/shortcuts/types'
import { useActiveElement } from '@vueuse/core'
import { useTheme } from '~/lib/core/composables/theme'
import { useMixpanel } from '~/lib/core/composables/mp'
export function useSectionBoxUtilities() {
const { instance } = useInjectedViewer()
const {
sectionBox,
sectionBoxContext: { visible, edited },
filters: { selectedObjects },
threads: {
openThread: { thread }
}
} = useInjectedViewerInterfaceState()
const isSectionBoxEnabled = computed(() => !!sectionBox.value)
const isSectionBoxVisible = computed(() => visible.value)
const isSectionBoxEdited = computed(() => edited.value)
const resolveSectionBoxFromSelection = () => {
const objectIds = selectedObjects.value.map((o) => o.id).filter(isNonNullable)
const box = instance.getRenderer().boxFromObjects(objectIds)
/** When generating a section box from selection we don't apply any rotation */
sectionBox.value = {
min: box.min.toArray(),
max: box.max.toArray()
}
}
const toggleSectionBox = () => {
if (!isSectionBoxEnabled.value) {
resolveSectionBoxFromSelection()
return
}
if (isSectionBoxVisible.value) {
visible.value = false
} else {
visible.value = true
}
}
const resetSectionBox = () => {
const serializedSectionBox = thread.value?.viewerState?.ui.sectionBox
sectionBox.value = null
if (serializedSectionBox) {
// Same logic we have in deserialization
sectionBox.value = {
min: serializedSectionBox.min,
max: serializedSectionBox.max,
rotation: serializedSectionBox.rotation
}
}
}
return {
isSectionBoxEnabled,
isSectionBoxVisible,
isSectionBoxEdited,
toggleSectionBox,
resetSectionBox,
sectionBox
}
}
export function useCameraUtilities() {
const { instance } = useInjectedViewer()
const {
filters: { selectedObjects, isolatedObjectIds },
camera
} = useInjectedViewerInterfaceState()
const zoom = (...args: Parameters<typeof instance.zoom>) => instance.zoom(...args)
const setView = (...args: Parameters<typeof instance.setView>) => {
instance.setView(...args)
}
const zoomExtentsOrSelection = () => {
const ids = selectedObjects.value.map((o) => o.id).filter(isNonNullable)
if (ids.length > 0) {
return instance.zoom(ids)
}
if (isolatedObjectIds.value.length) {
return instance.zoom(isolatedObjectIds.value)
}
instance.zoom()
}
const toggleProjection = () => {
camera.isOrthoProjection.value = !camera.isOrthoProjection.value
}
const forceViewToViewerSync = () => {
setView({
position: camera.position.value,
target: camera.target.value
})
}
return {
zoomExtentsOrSelection,
toggleProjection,
camera,
setView,
zoom,
forceViewToViewerSync
}
}
export function useFilterUtilities(
options?: Partial<{ state: InjectableViewerState }>
) {
const state = options?.state || useInjectedViewerState()
const {
viewer,
ui: { filters, explodeFactor }
} = state
const isolateObjects = (
objectIds: string[],
options?: Partial<{
replace: boolean
}>
) => {
filters.isolatedObjectIds.value = uniq([
...(options?.replace ? [] : filters.isolatedObjectIds.value),
...objectIds
])
// instance.isolateObjects(objectIds, 'utilities', true)
}
const unIsolateObjects = (objectIds: string[]) => {
filters.isolatedObjectIds.value = difference(
filters.isolatedObjectIds.value,
objectIds
)
// instance.unIsolateObjects(objectIds, 'utilities', true)
}
const hideObjects = (
objectIds: string[],
options?: Partial<{
replace: boolean
}>
) => {
filters.hiddenObjectIds.value = uniq([
...(options?.replace ? [] : filters.hiddenObjectIds.value),
...objectIds
])
// instance.hideObjects(objectIds, 'utilities', true)
}
const showObjects = (objectIds: string[]) => {
filters.hiddenObjectIds.value = difference(filters.hiddenObjectIds.value, objectIds)
// instance.showObjects(objectIds, 'utilities', true)
}
/**
* Sets the current filter property. Does not apply it (instruct viewer to color objects).
*/
const setPropertyFilter = (property: PropertyInfo) => {
filters.propertyFilter.filter.value = property
}
/**
* Instructs the viewer to apply the current property filter (color objects).
*/
const applyPropertyFilter = () => {
filters.propertyFilter.isApplied.value = true
}
/**
* Unsets the current property filter.
*/
const removePropertyFilter = () => {
filters.propertyFilter.isApplied.value = false
filters.propertyFilter.filter.value = null
}
/**
* Unapplies the current property filter - removes object colouring
*/
const unApplyPropertyFilter = () => {
filters.propertyFilter.isApplied.value = false
}
const resetFilters = () => {
filters.hiddenObjectIds.value = []
filters.isolatedObjectIds.value = []
filters.propertyFilter.filter.value = null
filters.propertyFilter.isApplied.value = false
explodeFactor.value = 0
// filters.selectedObjects.value = []
}
const waitForAvailableFilter = async (
key: string,
options?: Partial<{ timeout: number }>
) => {
const timeout = options?.timeout || 10 * TIME_MS.second
const res = await Promise.race([
until(viewer.metadata.availableFilters).toMatch(
(filters) => !!filters?.find((p) => p.key === key)
),
timeoutAt(timeout, 'Waiting for available filter timed out')
])
const filter = res?.find((p) => p.key === key)
return filter as NonNullable<typeof filter>
}
return {
isolateObjects,
unIsolateObjects,
hideObjects,
showObjects,
filters,
setPropertyFilter,
applyPropertyFilter,
removePropertyFilter,
unApplyPropertyFilter,
resetFilters,
waitForAvailableFilter
}
}
export function useSelectionUtilities() {
const {
filters: { selectedObjects, selectedObjectIds }
} = useInjectedViewerInterfaceState()
const { metadata } = useInjectedViewer()
const setSelectionFromObjectIds = (objectIds: string[]) => {
const objs: Array<SpeckleObject> = []
objectIds.forEach((value: string) => {
objs.push(
...(
(metadata?.worldTree.value?.findId(value) || []) as unknown as TreeNode[]
).map(
(node: TreeNode) =>
(node.model as Record<string, unknown>).raw as SpeckleObject
)
)
})
selectedObjects.value = objs
}
const addToSelectionFromObjectIds = (objectIds: string[]) => {
const originalObjects = selectedObjects.value.slice()
setSelectionFromObjectIds(objectIds)
selectedObjects.value = [...originalObjects, ...selectedObjects.value]
}
const removeFromSelectionObjectIds = (objectIds: string[]) => {
const finalObjects = selectedObjects.value.filter(
(o) => !objectIds.includes(o.id || '')
)
selectedObjects.value = finalObjects
}
const addToSelection = (object: SpeckleObject) => {
const idx = selectedObjects.value.findIndex((o) => o.id === object.id)
if (idx !== -1) return
selectedObjects.value = [...selectedObjects.value, object]
}
const removeFromSelection = (objectOrId: SpeckleObject | string) => {
const oid = isString(objectOrId) ? objectOrId : objectOrId.id
const idx = selectedObjects.value.findIndex((o) => o.id === oid)
if (idx === -1) return
const newObjects = selectedObjects.value.slice()
newObjects.splice(idx, 1)
selectedObjects.value = newObjects
}
const clearSelection = () => {
selectedObjects.value = []
}
return {
addToSelection,
removeFromSelection,
clearSelection,
setSelectionFromObjectIds,
addToSelectionFromObjectIds,
removeFromSelectionObjectIds,
objects: selectedObjects,
objectIds: selectedObjectIds
}
}
export function useDiffUtilities() {
const state = useInjectedViewerState()
const { serializeDiffCommand, deserializeDiffCommand, areDiffsEqual } =
useDiffBuilderUtilities()
const endDiff = async () => {
await state.urlHashState.diff.update(null)
}
const diffModelVersions = async (
modelId: string,
versionA: string,
versionB: string
) => {
await state.urlHashState.diff.update({
diffs: [
{
versionA: new SpeckleViewer.ViewerRoute.ViewerVersionResource(
modelId,
versionA
),
versionB: new SpeckleViewer.ViewerRoute.ViewerVersionResource(
modelId,
versionB
)
}
]
})
}
return {
serializeDiffCommand,
deserializeDiffCommand,
endDiff,
diffModelVersions,
areDiffsEqual
}
}
export function useThreadUtilities() {
const {
urlHashState: { focusedThreadId },
ui: {
threads: {
openThread: { thread: openThread }
}
}
} = useInjectedViewerState()
const isOpenThread = (id: string) => focusedThreadId.value === id
const closeAllThreads = async () => {
await focusedThreadId.update(null)
}
const open = async (id: string) => {
if (id === focusedThreadId.value) return
await focusedThreadId.update(id)
await Promise.all([
until(focusedThreadId).toMatch((tid) => tid === id),
until(openThread).toMatch((t) => t?.id === id)
])
}
return { closeAllThreads, open, isOpenThread }
}
export function useMeasurementUtilities() {
const state = useInjectedViewerState()
const measurementOptions = computed(() => state.ui.measurement.options.value)
const enableMeasurements = (enabled: boolean) => {
state.ui.measurement.enabled.value = enabled
}
const setMeasurementOptions = (options: MeasurementOptions) => {
state.ui.measurement.options.value = options
}
const removeMeasurement = () => {
if (state.viewer.instance?.removeMeasurement) {
state.viewer.instance.removeMeasurement()
}
}
const clearMeasurements = () => {
state.viewer.instance.getExtension(MeasurementsExtension).clearMeasurements()
}
const getActiveMeasurement = () => {
const measurementsExtension =
state.viewer.instance.getExtension(MeasurementsExtension)
const activeMeasurement = measurementsExtension?.activeMeasurement
return activeMeasurement && activeMeasurement.state === 2
}
return {
measurementOptions,
enableMeasurements,
setMeasurementOptions,
removeMeasurement,
clearMeasurements,
getActiveMeasurement
}
}
/**
* Some conditional rendering values depend on multiple & overlapping states. This utility reconciles that.
*/
export function useConditionalViewerRendering() {
const embedMode = useEmbedState()
const showControls = computed(() => {
if (
embedMode.embedOptions.value?.isEnabled &&
embedMode.embedOptions.value.hideControls
) {
return false
}
return true
})
const showNavbar = computed(() => {
if (!showControls.value) return false
if (embedMode.embedOptions.value?.isEnabled) return false
return true
})
return {
showNavbar,
showControls
}
}
export function useHighlightedObjectsUtilities() {
const {
ui: { highlightedObjectIds }
} = useInjectedViewerState()
const highlightObjects = (ids: string[]) => {
highlightedObjectIds.value = [...new Set([...highlightedObjectIds.value, ...ids])]
}
const unhighlightObjects = (ids: string[]) => {
highlightedObjectIds.value = highlightedObjectIds.value.filter(
(id) => !ids.includes(id)
)
}
const clearHighlightedObjects = () => {
highlightedObjectIds.value = []
}
return {
highlightObjects,
unhighlightObjects,
clearHighlightedObjects
}
}
export function useViewModeUtilities() {
const { instance } = useInjectedViewer()
const { viewMode } = useInjectedViewerInterfaceState()
const { isLightTheme } = useTheme()
const mp = useMixpanel()
const edgesEnabled = ref(true)
const edgesWeight = ref(1)
const outlineOpacity = ref(0.75)
const defaultColor = ref(0x1a1a1a)
const edgesColor = ref(defaultColor.value)
const currentViewMode = computed(() => viewMode.value)
const updateViewMode = () => {
const viewModes = instance.getExtension(ViewModes)
if (viewModes) {
viewModes.setViewMode(currentViewMode.value, {
edges: edgesEnabled.value,
outlineThickness: edgesWeight.value,
outlineOpacity: outlineOpacity.value,
outlineColor: edgesColor.value
})
}
}
const setViewMode = (mode: ViewMode) => {
viewMode.value = mode
if (mode === ViewMode.PEN) {
outlineOpacity.value = 1
edgesEnabled.value = true
if (edgesColor.value === defaultColor.value) {
if (!isLightTheme.value) {
edgesColor.value = 0xffffff
}
}
} else {
outlineOpacity.value = 0.75
if (edgesColor.value === 0xffffff) {
edgesColor.value = isLightTheme.value ? 0xffffff : defaultColor.value
}
}
updateViewMode()
mp.track('Viewer Action', {
type: 'action',
name: 'set-view-mode',
mode
})
}
const toggleEdgesEnabled = () => {
edgesEnabled.value = !edgesEnabled.value
updateViewMode()
mp.track('Viewer Action', {
type: 'action',
name: 'toggle-edges',
enabled: edgesEnabled.value
})
}
const setEdgesWeight = (weight: number) => {
edgesWeight.value = Number(weight)
updateViewMode()
mp.track('Viewer Action', {
type: 'action',
name: 'set-edges-weight',
weight: edgesWeight.value
})
}
const setEdgesColor = (color: number) => {
edgesColor.value = color
updateViewMode()
mp.track('Viewer Action', {
type: 'action',
name: 'set-edges-color',
color: color.toString(16).padStart(6, '0')
})
}
onBeforeUnmount(() => {
// Reset edges settings
edgesEnabled.value = true
edgesWeight.value = 1
outlineOpacity.value = 0.75
edgesColor.value = defaultColor.value
// Reset view mode to default
viewMode.value = ViewMode.DEFAULT
updateViewMode()
})
return {
currentViewMode,
setViewMode,
edgesEnabled,
toggleEdgesEnabled,
edgesWeight,
setEdgesWeight,
setEdgesColor,
edgesColor
}
}
export function useViewerShortcuts() {
const { ui } = useInjectedViewerState()
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
const { isEnabled: isEmbedEnabled } = useEmbed()
const activeElement = useActiveElement()
const isTypingComment = computed(() => {
if (
activeElement.value &&
(activeElement.value.tagName.toLowerCase() === 'input' ||
activeElement.value.tagName.toLowerCase() === 'textarea' ||
activeElement.value.getAttribute('contenteditable') === 'true')
) {
return true
}
// Check thread editor states
const isNewThreadEditorOpen = ui.threads.openThread.newThreadEditor.value
const isExistingThreadEditorOpen = !!ui.threads.openThread.thread.value
return isNewThreadEditorOpen || isExistingThreadEditorOpen
})
const formatKey = (key: string) => {
if (key.startsWith('Digit')) {
return key.slice(5)
}
return key
}
const getShortcutDisplayText = (
shortcut: ViewerShortcut,
options?: { hideName?: boolean }
) => {
if (isSmallerOrEqualSm.value) return undefined
if (isEmbedEnabled.value) return undefined
const shortcutText = getKeyboardShortcutTitle([
...shortcut.modifiers,
formatKey(shortcut.key)
])
if (!options?.hideName) {
return `${shortcut.name} (${shortcutText})`
}
return shortcutText
}
const disableShortcuts = computed(() => isTypingComment.value || isEmbedEnabled.value)
const registerShortcuts = (
handlers: Partial<Record<ViewerShortcutAction, () => void>>
) => {
Object.values(ViewerShortcuts).forEach((shortcut) => {
const handler = handlers[shortcut.action as ViewerShortcutAction]
if (handler) {
onKeyboardShortcut([...shortcut.modifiers], shortcut.key, () => {
if (!disableShortcuts.value) handler()
})
}
})
}
return {
shortcuts: ViewerShortcuts,
registerShortcuts,
getShortcutDisplayText
}
}