Files
speckle-server/packages/frontend-2/components/viewer/Controls.vue
T
andrewwallacespeckle c8bdf01cdd FE2 Viewer - Add Measure Mode (#1889)
* RadioGroup & Initial UI for Measure

* Add option to Panel to allow actions to move to bottom

* Typo

* Add count to precision

* Add enable, snap and type api integrations

* Update Units WIP

* Add precision update

* Update v-tippy name

* Updates

* New design

* Better darkmode radio. Keystrokes.

* Styling fixes. Fix select mount-menu-on-body

* Fix ts bug

* Show label in Select for units

* Update shortcut to D

* Small design changes

* Small tidy ups

* WIP New Measurements Helper State

* Fix build erros

* Remove viewer import from shared

* Delete WIP

* Fix delete

* Fix close button on measure mode

* Measurement nullable

* Updates from PR

* Seperate measurements into measurementsEnabled & measurementOptions

* Update state.ts

* Update ts bugs

* Updates to RadioGroup

* Use ctx.updateArgs

* Replace RadioGroup with Radio - More consistent with existing inputs

* Update FE2 to use new Radio

* Fix circleci fail

* Fix build

* Fix wrong initial state for vertexSnap

* Adjust type to measurement

* Use Lodash isEqual

* Fix bug where units don't update

* Remove double input

* Fix server error in data.ts

* Revert change around useEqual
2023-12-06 09:56:22 +00:00

406 lines
12 KiB
Vue

<template>
<div>
<div
class="absolute z-20 flex h-[100dvh] flex-col space-y-2 bg-green-300/0 px-2 pt-[4.2rem]"
>
<!-- Models -->
<ViewerControlsButtonToggle
v-tippy="modelsShortcut"
:active="activeControl === 'models'"
@click="toggleActiveControl('models')"
>
<CubeIcon class="h-5 w-5" />
</ViewerControlsButtonToggle>
<!-- Explorer -->
<ViewerControlsButtonToggle
v-tippy="explorerShortcut"
:active="activeControl === 'explorer'"
@click="toggleActiveControl('explorer')"
>
<IconFileExplorer class="h-5 w-5" />
</ViewerControlsButtonToggle>
<!-- Measurements -->
<ViewerControlsButtonToggle
v-tippy="measureShortcut"
:active="activeControl === 'measurements'"
@click="toggleMeasurements"
>
<IconMeasurements class="h-5 w-5" />
</ViewerControlsButtonToggle>
<!-- TODO -->
<!-- <ViewerControlsButtonToggle
:active="activeControl === 'filters'"
@click="toggleActiveControl('filters')"
>
<FunnelIcon class="w-5 h-5" />
</ViewerControlsButtonToggle> -->
<!-- Comment threads -->
<ViewerControlsButtonToggle
v-tippy="discussionsShortcut"
:active="activeControl === 'discussions'"
@click="toggleActiveControl('discussions')"
>
<ChatBubbleLeftRightIcon class="h-5 w-5" />
</ViewerControlsButtonToggle>
<!-- Automateeeeeeee FTW -->
<ViewerControlsButtonToggle
v-if="allAutomationRuns.length !== 0"
v-tippy="summary.longSummary"
:active="activeControl === 'automate'"
class="p-2"
@click="toggleActiveControl('automate')"
>
<!-- <PlayCircleIcon class="h-5 w-5" /> -->
<!-- {{allAutomationRuns.length}} -->
<AutomationDoughnutSummary :summary="summary" />
</ViewerControlsButtonToggle>
<!-- TODO: direct add comment -->
<!-- <ViewerCommentsDirectAddComment v-show="activeControl === 'comments'" /> -->
<!-- Standard viewer controls -->
<ViewerControlsButtonGroup>
<!-- Views -->
<ViewerViewsMenu v-tippy="'Views'" />
<!-- Zoom extents -->
<ViewerControlsButtonToggle
v-tippy="zoomExtentsShortcut"
flat
@click="trackAndzoomExtentsOrSelection()"
>
<ArrowsPointingOutIcon class="h-5 w-5" />
</ViewerControlsButtonToggle>
<!-- Sun and lights -->
<ViewerSunMenu v-tippy="'Light Controls'" />
</ViewerControlsButtonGroup>
<ViewerControlsButtonGroup>
<!-- Projection type -->
<!-- TODO (Question for fabs): How to persist state between page navigation? e.g., swap to iso mode, move out, move back, iso mode is still on in viewer but not in ui -->
<ViewerControlsButtonToggle
v-tippy="projectionShortcut"
flat
secondary
:active="isOrthoProjection"
@click="trackAndtoggleProjection()"
>
<IconPerspective v-if="isOrthoProjection" class="h-4 w-4" />
<IconPerspectiveMore v-else class="h-4 w-4" />
</ViewerControlsButtonToggle>
<!-- Section Box -->
<ViewerControlsButtonToggle
v-tippy="sectionBoxShortcut"
flat
secondary
:active="isSectionBoxEnabled"
@click="toggleSectionBox()"
>
<ScissorsIcon class="h-5 w-5" />
</ViewerControlsButtonToggle>
<!-- Explosion -->
<ViewerExplodeMenu v-tippy="'Explode'" />
<!-- Settings -->
<ViewerSettingsMenu />
</ViewerControlsButtonGroup>
</div>
<div
ref="scrollableControlsContainer"
:class="`simple-scrollbar absolute z-10 mx-14 mt-[4rem] mb-4 max-h-[calc(100dvh-5.5rem)] w-64 sm:w-72 overflow-y-auto px-[2px] py-[2px] transition ${
activeControl !== 'none'
? 'translate-x-0 opacity-100'
: '-translate-x-[100%] opacity-0'
}`"
>
<div v-show="activeControl.length !== 0 && activeControl === 'measurements'">
<KeepAlive>
<div><ViewerMeasurementsOptions @close="toggleMeasurements" /></div>
</KeepAlive>
</div>
<div v-show="resourceItems.length !== 0 && activeControl === 'models'">
<KeepAlive>
<div>
<ViewerResourcesList
v-if="!enabled"
class="pointer-events-auto"
@loaded-more="scrollControlsToBottom"
@close="activeControl = 'none'"
/>
<ViewerCompareChangesPanel v-else @close="activeControl = 'none'" />
</div>
</KeepAlive>
</div>
<div v-show="resourceItems.length !== 0 && activeControl === 'explorer'">
<KeepAlive>
<ViewerExplorer class="pointer-events-auto" @close="activeControl = 'none'" />
</KeepAlive>
</div>
<ViewerComments
v-if="resourceItems.length !== 0 && activeControl === 'discussions'"
class="pointer-events-auto"
@close="activeControl = 'none'"
/>
<ViewerFilters
v-if="resourceItems.length !== 0 && activeControl === 'filters'"
class="pointer-events-auto"
/>
<div v-show="resourceItems.length !== 0 && activeControl === 'automate'">
<ViewerAutomatePanel
:automation-runs="allAutomationRuns"
:summary="summary"
@close="activeControl = 'none'"
/>
</div>
<!-- Empty state -->
<div v-if="resourceItems.length === 0">
<div class="flex items-center py-3 px-2">
<div class="text-sm text-foreground-2">No models loaded.</div>
<div>
<FormButton
size="xs"
text
:icon-left="PlusIcon"
@click="openAddModel = true"
>
Add
</FormButton>
<ViewerResourcesAddModelDialog v-model:open="openAddModel" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
CubeIcon,
ChatBubbleLeftRightIcon,
ArrowsPointingOutIcon,
ScissorsIcon,
PlusIcon
} from '@heroicons/vue/24/outline'
import type { Nullable } from '@speckle/shared'
import {
useCameraUtilities,
useSectionBoxUtilities,
useMeasurementUtilities
} from '~~/lib/viewer/composables/ui'
import {
onKeyboardShortcut,
ModifierKeys,
getKeyboardShortcutTitle
} from '@speckle/ui-components'
import {
useInjectedViewerLoadedResources,
useInjectedViewerInterfaceState
} from '~~/lib/viewer/composables/setup'
import { useMixpanel } from '~~/lib/core/composables/mp'
const {
zoomExtentsOrSelection,
toggleProjection,
camera: { isOrthoProjection }
} = useCameraUtilities()
import { AutomationRunStatus } from '~~/lib/common/generated/gql/graphql'
import type { AutomationRun } from '~~/lib/common/generated/gql/graphql'
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
const { resourceItems, modelsAndVersionIds } = useInjectedViewerLoadedResources()
const { toggleSectionBox, isSectionBoxEnabled } = useSectionBoxUtilities()
const { enableMeasurements } = useMeasurementUtilities()
const allAutomationRuns = computed(() => {
const allAutomationStatuses = modelsAndVersionIds.value
.map((model) => model.model.loadedVersion.items[0].automationStatus)
.flat()
.filter((run) => !!run)
return allAutomationStatuses
.map((status) => status?.automationRuns)
.flat() as AutomationRun[]
})
const allFunctionRuns = computed(() => {
return allAutomationRuns.value.map((run) => run.functionRuns).flat()
})
const summary = computed(() => {
const result = {
failed: 0,
passed: 0,
inProgress: 0,
total: allFunctionRuns.value.length,
title: 'All runs passed.',
titleColor: 'text-success',
longSummary: ''
}
for (const run of allFunctionRuns.value) {
switch (run?.status) {
case AutomationRunStatus.Succeeded:
result.passed++
break
case AutomationRunStatus.Failed:
result.title = 'Some runs failed.'
result.titleColor = 'text-danger'
result.failed++
break
default:
if (result.failed === 0) {
result.title = 'Some runs are still in progress.'
result.titleColor = 'text-warning'
}
result.inProgress++
break
}
}
// format:
// 2 failed, 1 passed runs
// 1 passed, 2 in progress, 1 failed runs
// 1 passed run
const longSummarySegments = []
if (result.passed > 0) longSummarySegments.push(`${result.passed} passed`)
if (result.inProgress > 0)
longSummarySegments.push(`${result.inProgress} in progress`)
if (result.failed > 0) longSummarySegments.push(`${result.failed} failed`)
result.longSummary = (
longSummarySegments.join(', ') + ` run${result.total > 1 ? 's' : ''}.`
).replace(/,(?=[^,]+$)/, ', and')
return result
})
type ActiveControl =
| 'none'
| 'models'
| 'explorer'
| 'filters'
| 'discussions'
| 'automate'
| 'measurements'
const openAddModel = ref(false)
const activeControl = ref<ActiveControl>('models')
const scrollableControlsContainer = ref(null as Nullable<HTMLDivElement>)
const {
diff: { enabled }
} = useInjectedViewerInterfaceState()
const modelsShortcut = ref(
`Models (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'm'])})`
)
const explorerShortcut = ref(
`Scene Explorer (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'e'])})`
)
const discussionsShortcut = ref(
`Discussions (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 't'])})`
)
const zoomExtentsShortcut = ref(
`Fit to screen (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'Space'])})`
)
const projectionShortcut = ref(
`Projection (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'p'])})`
)
const sectionBoxShortcut = ref(
`Section Box (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'b'])})`
)
const measureShortcut = ref(
`Measure Mode (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'd'])})`
)
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
const toggleActiveControl = (control: ActiveControl) =>
activeControl.value === control
? (activeControl.value = 'none')
: (activeControl.value = control)
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'm', () => {
toggleActiveControl('models')
})
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'e', () => {
toggleActiveControl('explorer')
})
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'f', () => {
toggleActiveControl('filters')
})
onKeyboardShortcut([ModifierKeys.AltOrOpt], ['t'], () => {
toggleActiveControl('discussions')
})
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'd', () => {
toggleActiveControl('measurements')
})
// Viewer actions kbd shortcuts
onKeyboardShortcut([ModifierKeys.AltOrOpt], ' ', () => {
trackAndzoomExtentsOrSelection()
})
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'p', () => {
toggleProjection()
})
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'b', () => {
toggleSectionBox()
})
const mp = useMixpanel()
watch(activeControl, (newVal) => {
mp.track('Viewer Action', { type: 'action', name: 'controls-toggle', action: newVal })
})
const trackAndzoomExtentsOrSelection = () => {
zoomExtentsOrSelection()
mp.track('Viewer Action', { type: 'action', name: 'zoom', source: 'button' })
}
const trackAndtoggleProjection = () => {
toggleProjection()
mp.track('Viewer Action', {
type: 'action',
name: 'camera',
camera: isOrthoProjection ? 'ortho' : 'perspective'
})
}
watch(isSectionBoxEnabled, (val) => {
mp.track('Viewer Action', {
type: 'action',
name: 'section-box',
status: val
})
})
const scrollControlsToBottom = () => {
// TODO: Currently this will scroll to the very bottom, which doesn't make sense when there are multiple models loaded
// if (scrollableControlsContainer.value)
// scrollToBottom(scrollableControlsContainer.value)
}
const toggleMeasurements = () => {
const isMeasurementsActive = activeControl.value === 'measurements'
enableMeasurements(!isMeasurementsActive)
activeControl.value = isMeasurementsActive ? 'none' : 'measurements'
}
onMounted(() => {
activeControl.value = isSmallerOrEqualSm.value ? 'none' : 'models'
})
watch(isSmallerOrEqualSm, (newVal) => {
activeControl.value = newVal ? 'none' : 'models'
})
</script>