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
This commit is contained in:
committed by
GitHub
parent
a6b7266b85
commit
c8bdf01cdd
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<svg
|
||||
width="36"
|
||||
height="36"
|
||||
viewBox="0 0 36 36"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M1.5 23.9655V3.00003L10.5 10.8621V31.5L1.5 23.9655Z"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M1.5 3.00003L22.5 1.50003"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M1.5 24L22.5 22.5"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-dasharray="2 2"
|
||||
/>
|
||||
<path
|
||||
d="M10.5 31.5L31.5 30"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.5 11.25L31.5 9.75003"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M22.5 1.50003L31.5 9.34814V30"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M22.5 1.50003V22.5"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-dasharray="2 2"
|
||||
/>
|
||||
<path
|
||||
d="M22.5 22.5L31.5 30"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-dasharray="2 2"
|
||||
/>
|
||||
<path
|
||||
d="M6.04926 17.378L27.0493 15.878"
|
||||
stroke="#3B82F6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.37823 19.3902L7.10927 15.3896"
|
||||
stroke="#3B82F6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.6345 17.8902L28.3656 13.8896"
|
||||
stroke="#3B82F6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.96887 14.093L7.03114 20.663"
|
||||
stroke="#3B82F6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M26.2251 12.593L28.2874 19.163"
|
||||
stroke="#3B82F6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<svg
|
||||
width="36"
|
||||
height="36"
|
||||
viewBox="0 0 36 36"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 25.4655V4.50003L12 12.3621V33L3 25.4655Z"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 4.50003L24 3.00003"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 25.5L24 24"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-dasharray="2 2"
|
||||
/>
|
||||
<path
|
||||
d="M12 33L33 31.5"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 12.75L33 11.25"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M24 3.00003L33 10.8481V31.5"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M24 3.00003V24"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-dasharray="2 2"
|
||||
/>
|
||||
<path
|
||||
d="M24 24L33 31.5"
|
||||
stroke="#CBD5E1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-dasharray="2 2"
|
||||
/>
|
||||
<path
|
||||
d="M3 25.5L33 10.5"
|
||||
stroke="#3B82F6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="3" cy="25.5" r="2.25" fill="#3B82F6" />
|
||||
<circle cx="33" cy="10.5" r="2.25" fill="#3B82F6" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-ruler-measure"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M19.875 12c.621 0 1.125 .512 1.125 1.143v5.714c0 .631 -.504 1.143 -1.125 1.143h-15.875a1 1 0 0 1 -1 -1v-5.857c0 -.631 .504 -1.143 1.125 -1.143h15.75z"
|
||||
/>
|
||||
<path d="M9 12v2" />
|
||||
<path d="M6 12v3" />
|
||||
<path d="M12 12v3" />
|
||||
<path d="M18 12v3" />
|
||||
<path d="M15 12v2" />
|
||||
<path d="M3 3v4" />
|
||||
<path d="M3 5h18" />
|
||||
<path d="M21 3v4" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@
|
||||
<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'"
|
||||
@@ -10,6 +11,8 @@
|
||||
>
|
||||
<CubeIcon class="h-5 w-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
|
||||
<!-- Explorer -->
|
||||
<ViewerControlsButtonToggle
|
||||
v-tippy="explorerShortcut"
|
||||
:active="activeControl === 'explorer'"
|
||||
@@ -18,6 +21,15 @@
|
||||
<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'"
|
||||
@@ -107,6 +119,11 @@
|
||||
: '-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>
|
||||
@@ -173,7 +190,8 @@ import {
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import {
|
||||
useCameraUtilities,
|
||||
useSectionBoxUtilities
|
||||
useSectionBoxUtilities,
|
||||
useMeasurementUtilities
|
||||
} from '~~/lib/viewer/composables/ui'
|
||||
import {
|
||||
onKeyboardShortcut,
|
||||
@@ -200,6 +218,8 @@ 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)
|
||||
@@ -269,6 +289,7 @@ type ActiveControl =
|
||||
| 'filters'
|
||||
| 'discussions'
|
||||
| 'automate'
|
||||
| 'measurements'
|
||||
|
||||
const openAddModel = ref(false)
|
||||
|
||||
@@ -297,6 +318,9 @@ const projectionShortcut = ref(
|
||||
const sectionBoxShortcut = ref(
|
||||
`Section Box (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'b'])})`
|
||||
)
|
||||
const measureShortcut = ref(
|
||||
`Measure Mode (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'd'])})`
|
||||
)
|
||||
|
||||
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
|
||||
|
||||
@@ -317,6 +341,9 @@ onKeyboardShortcut([ModifierKeys.AltOrOpt], 'f', () => {
|
||||
onKeyboardShortcut([ModifierKeys.AltOrOpt], ['t'], () => {
|
||||
toggleActiveControl('discussions')
|
||||
})
|
||||
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'd', () => {
|
||||
toggleActiveControl('measurements')
|
||||
})
|
||||
|
||||
// Viewer actions kbd shortcuts
|
||||
onKeyboardShortcut([ModifierKeys.AltOrOpt], ' ', () => {
|
||||
@@ -362,6 +389,12 @@ const scrollControlsToBottom = () => {
|
||||
// scrollToBottom(scrollableControlsContainer.value)
|
||||
}
|
||||
|
||||
const toggleMeasurements = () => {
|
||||
const isMeasurementsActive = activeControl.value === 'measurements'
|
||||
enableMeasurements(!isMeasurementsActive)
|
||||
activeControl.value = isMeasurementsActive ? 'none' : 'measurements'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
activeControl.value = isSmallerOrEqualSm.value ? 'none' : 'models'
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="bg-foundation sm:rounded-lg overflow-hidden shadow">
|
||||
<div class="sticky top-0 z-50 flex flex-col bg-foundation shadow-md">
|
||||
<div class="bg-foundation sm:rounded-lg overflow-hidden shadow flex flex-col">
|
||||
<div class="sticky top-0 z-50 flex flex-col bg-foundation">
|
||||
<div v-if="!hideClose" class="absolute top-2 right-2 sm:right-0 z-10">
|
||||
<FormButton size="sm" color="secondary" text @click="$emit('close')">
|
||||
<XMarkIcon class="h-4 w-4 sm:h-3 sm:w-3 text-primary sm:text-foreground" />
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.title"
|
||||
class="flex items-center h-10 px-3 border-b border-outline dark:border-foundation-2 bg-foundation rounded-t"
|
||||
class="flex items-center h-10 px-3 border-b border-outline-3 dark:border-foundation-2 bg-foundation rounded-t"
|
||||
>
|
||||
<div
|
||||
class="flex items-center h-full w-full pr-8 font-semibold sm:font-bold text-sm text-primary"
|
||||
@@ -18,11 +18,19 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="flex items-center h-8 sm:h-10 gap-2 px-2">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="flex items-center h-8 sm:h-10 gap-2 px-2"
|
||||
:class="
|
||||
moveActionsToBottom
|
||||
? 'order-3 border-t border-outline-3 mt-2'
|
||||
: 'order-2 shadow-md'
|
||||
"
|
||||
>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
<div :class="moveActionsToBottom ? 'order-2' : 'order-3'">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,6 +42,10 @@ defineProps({
|
||||
hideClose: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
moveActionsToBottom: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<ViewerLayoutPanel move-actions-to-bottom @close="$emit('close')">
|
||||
<template #title>Measure Mode</template>
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm px-3 py-2 border-b border-outline-3 text-foreground-2"
|
||||
>
|
||||
<InformationCircleIcon class="h-6 h-6 shrink-0" />
|
||||
<span class="max-w-[210px]">
|
||||
Reloading the model will delete all measurements.
|
||||
</span>
|
||||
</div>
|
||||
<template #actions>
|
||||
<FormButton
|
||||
size="sm"
|
||||
text
|
||||
color="danger"
|
||||
:icon-left="TrashIcon"
|
||||
class="font-normal py-1"
|
||||
@click="() => removeMeasurement()"
|
||||
>
|
||||
Delete Selected
|
||||
</FormButton>
|
||||
</template>
|
||||
<div class="p-3 flex flex-col gap-3 border-b border-outline-3">
|
||||
<div>
|
||||
<h6 class="font-semibold text-sm mb-2">Measurement Type</h6>
|
||||
<FormRadio
|
||||
v-for="option in measurementTypeOptions"
|
||||
:key="option.value"
|
||||
:label="option.title"
|
||||
:value="option.value.toString()"
|
||||
name="measurementType"
|
||||
:icon="option.icon"
|
||||
:checked="measurementParams.type === option.value"
|
||||
@change="updateMeasurementsType(option)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 flex items-center border-b border-outline-3">
|
||||
<FormCheckbox
|
||||
name="Snap to Objects"
|
||||
hide-label
|
||||
:model-value="measurementParams.vertexSnap"
|
||||
@update:model-value="() => toggleMeasurementsSnap()"
|
||||
/>
|
||||
<span class="font-normal text-sm">Snap to Vertices</span>
|
||||
</div>
|
||||
<div class="p-3 flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="font-semibold text-sm">Units</h6>
|
||||
<ViewerMeasurementsUnitSelect
|
||||
v-model="selectedUnit"
|
||||
mount-menu-on-body
|
||||
@update:model-value="onChangeMeasurementUnits"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="font-semibold text-sm" for="precision">Precision</label>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="precision"
|
||||
v-model="measurementPrecision"
|
||||
class="h-2 mr-2 flex-1"
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
step="1"
|
||||
:onchange="onChangeMeasurementPrecision"
|
||||
/>
|
||||
<span class="text-xs w-4">{{ measurementPrecision }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ViewerLayoutPanel>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { InformationCircleIcon, TrashIcon } from '@heroicons/vue/24/outline'
|
||||
import { FormRadio } from '@speckle/ui-components'
|
||||
import { MeasurementType } from '@speckle/viewer'
|
||||
import { useMeasurementUtilities } from '~~/lib/viewer/composables/ui'
|
||||
import { resolveComponent } from 'vue'
|
||||
import type { ConcreteComponent } from 'vue'
|
||||
|
||||
interface MeasurementTypeOption {
|
||||
title: string
|
||||
value: MeasurementType
|
||||
}
|
||||
|
||||
defineEmits(['close'])
|
||||
|
||||
const measurementPrecision = ref(2)
|
||||
const selectedUnit = ref('m')
|
||||
|
||||
const measurementParams = ref({
|
||||
visible: true,
|
||||
type: MeasurementType.POINTTOPOINT,
|
||||
vertexSnap: true,
|
||||
units: selectedUnit.value,
|
||||
precision: measurementPrecision.value
|
||||
})
|
||||
|
||||
const { setMeasurementOptions, removeMeasurement } = useMeasurementUtilities()
|
||||
|
||||
const updateMeasurementsType = (selectedOption: MeasurementTypeOption) => {
|
||||
measurementParams.value.type = selectedOption.value
|
||||
setMeasurementOptions(measurementParams.value)
|
||||
}
|
||||
|
||||
const onChangeMeasurementUnits = (newUnit: string) => {
|
||||
selectedUnit.value = newUnit
|
||||
measurementParams.value.units = newUnit
|
||||
setMeasurementOptions(measurementParams.value)
|
||||
}
|
||||
|
||||
const toggleMeasurementsSnap = () => {
|
||||
measurementParams.value.vertexSnap = !measurementParams.value.vertexSnap
|
||||
setMeasurementOptions(measurementParams.value)
|
||||
}
|
||||
|
||||
const onChangeMeasurementPrecision = () => {
|
||||
measurementParams.value.precision = measurementPrecision.value
|
||||
setMeasurementOptions(measurementParams.value)
|
||||
}
|
||||
|
||||
const IconPointToPoint = resolveComponent(
|
||||
'IconMeasurePointToPoint'
|
||||
) as ConcreteComponent
|
||||
const IconPerpendicular = resolveComponent(
|
||||
'IconMeasurePerpendicular'
|
||||
) as ConcreteComponent
|
||||
|
||||
const measurementTypeOptions = [
|
||||
{
|
||||
title: 'Point to Point',
|
||||
icon: IconPointToPoint,
|
||||
value: MeasurementType.POINTTOPOINT
|
||||
},
|
||||
{
|
||||
title: 'Perpendicular',
|
||||
icon: IconPerpendicular,
|
||||
value: MeasurementType.PERPENDICULAR
|
||||
}
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<FormSelectBase
|
||||
v-model="selectedUnit"
|
||||
:items="units"
|
||||
label="Unit"
|
||||
:show-label="false"
|
||||
:name="name || 'units'"
|
||||
:allow-unset="false"
|
||||
>
|
||||
<template #something-selected>
|
||||
<div>{{ fullUnitName }}</div>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<div class="flex flex-col">
|
||||
<div class="label text-xs">{{ unitDisplayNames[item] || item }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
type UnitDisplayNames = {
|
||||
[unit: string]: string
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
name?: string
|
||||
}>()
|
||||
|
||||
// Use a ref for unitDisplayNames
|
||||
const unitDisplayNames = ref<UnitDisplayNames>({
|
||||
mm: 'Millimeters',
|
||||
cm: 'Centimeters',
|
||||
m: 'Meters',
|
||||
km: 'Kilometers',
|
||||
in: 'Inches',
|
||||
ft: 'Feet',
|
||||
yd: 'Yards',
|
||||
mi: 'Miles'
|
||||
})
|
||||
|
||||
function getFullUnitName(unit: string): string {
|
||||
return unitDisplayNames.value[unit] || unit
|
||||
}
|
||||
|
||||
const fullUnitName = computed(() => getFullUnitName(props.modelValue))
|
||||
|
||||
const units = computed(() => Object.keys(unitDisplayNames.value))
|
||||
|
||||
const selectedUnit = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (newVal) => emit('update:modelValue', newVal)
|
||||
})
|
||||
</script>
|
||||
@@ -115,7 +115,11 @@ export function useStateSerialization() {
|
||||
: null,
|
||||
lightConfig: { ...state.ui.lightConfig.value },
|
||||
explodeFactor: state.ui.explodeFactor.value,
|
||||
selection: state.ui.selection.value?.toArray() || null
|
||||
selection: state.ui.selection.value?.toArray() || null,
|
||||
measurement: {
|
||||
enabled: state.ui.measurement.enabled.value,
|
||||
options: state.ui.measurement.options.value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret
|
||||
|
||||
@@ -5,13 +5,15 @@ import {
|
||||
WorldTree,
|
||||
ViewerEvent,
|
||||
DefaultLightConfiguration,
|
||||
VisualDiffMode
|
||||
VisualDiffMode,
|
||||
MeasurementType
|
||||
} from '@speckle/viewer'
|
||||
import type {
|
||||
FilteringState,
|
||||
PropertyInfo,
|
||||
SunLightConfiguration,
|
||||
SpeckleView,
|
||||
MeasurementOptions,
|
||||
DiffResult
|
||||
} from '@speckle/viewer'
|
||||
import type { MaybeRef } from '@vueuse/shared'
|
||||
@@ -251,6 +253,10 @@ export type InjectableViewerState = Readonly<{
|
||||
explodeFactor: Ref<number>
|
||||
viewerBusy: WritableComputedRef<boolean>
|
||||
selection: Ref<Nullable<Vector3>>
|
||||
measurement: {
|
||||
enabled: Ref<boolean>
|
||||
options: Ref<MeasurementOptions>
|
||||
}
|
||||
}
|
||||
/**
|
||||
* State stored in the anchor string of the URL
|
||||
@@ -874,7 +880,17 @@ function setupInterfaceState(
|
||||
},
|
||||
hasAnyFiltersApplied
|
||||
},
|
||||
highlightedObjectIds
|
||||
highlightedObjectIds,
|
||||
measurement: {
|
||||
enabled: ref(false),
|
||||
options: ref<MeasurementOptions>({
|
||||
visible: true,
|
||||
type: MeasurementType.POINTTOPOINT,
|
||||
units: 'm',
|
||||
vertexSnap: true,
|
||||
precision: 2
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,6 +717,33 @@ function useDiffingIntegration() {
|
||||
})
|
||||
}
|
||||
|
||||
function useViewerMeasurementIntegration() {
|
||||
const {
|
||||
ui: { measurement },
|
||||
viewer: { instance }
|
||||
} = useInjectedViewerState()
|
||||
|
||||
watch(
|
||||
() => measurement.enabled.value,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
instance.enableMeasurements(newVal)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => ({ ...measurement.options.value }),
|
||||
(newMeasurementState) => {
|
||||
if (newMeasurementState) {
|
||||
instance.setMeasurementOptions(newMeasurementState)
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function useViewerPostSetup() {
|
||||
if (process.server) return
|
||||
useViewerObjectAutoLoading()
|
||||
@@ -731,5 +758,6 @@ export function useViewerPostSetup() {
|
||||
useLightConfigIntegration()
|
||||
useExplodeFactorIntegration()
|
||||
useDiffingIntegration()
|
||||
useViewerMeasurementIntegration()
|
||||
setupDebugMode()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SpeckleViewer, timeoutAt } from '@speckle/shared'
|
||||
import type { PropertyInfo } from '@speckle/viewer'
|
||||
import type { MeasurementOptions, PropertyInfo } from '@speckle/viewer'
|
||||
import { until } from '@vueuse/shared'
|
||||
import { difference, isString, uniq } from 'lodash-es'
|
||||
import type { SpeckleObject } from '~~/lib/common/helpers/sceneExplorer'
|
||||
@@ -339,3 +339,27 @@ export function useThreadUtilities() {
|
||||
|
||||
return { closeAllThreads, open, isOpenThread }
|
||||
}
|
||||
|
||||
export function useMeasurementUtilities() {
|
||||
const state = useInjectedViewerState()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enableMeasurements,
|
||||
setMeasurementOptions,
|
||||
removeMeasurement
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,10 @@ export async function convertLegacyDataToState(
|
||||
command: null,
|
||||
mode: 1,
|
||||
time: 0.5
|
||||
},
|
||||
measurement: {
|
||||
enabled: false,
|
||||
options: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,19 @@ import type { MaybeNullOrUndefined, Nullable } from '../../core/helpers/utilityT
|
||||
import type { PartialDeep } from 'type-fest'
|
||||
import { UnformattableSerializedViewerStateError } from '../errors'
|
||||
|
||||
enum MeasurementType {
|
||||
PERPENDICULAR = 0,
|
||||
POINTTOPOINT = 1
|
||||
}
|
||||
|
||||
interface MeasurementOptions {
|
||||
visible: boolean
|
||||
type?: MeasurementType
|
||||
vertexSnap?: boolean
|
||||
units?: string
|
||||
precision?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* v1 -> v1.1
|
||||
* - ui.filters.propertyFilter.isApplied field added
|
||||
@@ -73,6 +86,10 @@ export type SerializedViewerState = {
|
||||
}
|
||||
explodeFactor: number
|
||||
selection: Nullable<number[]>
|
||||
measurement: {
|
||||
enabled: boolean
|
||||
options: Nullable<MeasurementOptions>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +123,19 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState =
|
||||
)
|
||||
}
|
||||
|
||||
const defaultMeasurementOptions: MeasurementOptions = {
|
||||
visible: false,
|
||||
type: MeasurementType.POINTTOPOINT,
|
||||
vertexSnap: false,
|
||||
units: 'm',
|
||||
precision: 2
|
||||
}
|
||||
|
||||
const measurementOptions = {
|
||||
...defaultMeasurementOptions,
|
||||
...state.ui?.measurement?.options
|
||||
}
|
||||
|
||||
return {
|
||||
projectId: state.projectId || throwInvalidError('projectId'),
|
||||
sessionId: state.sessionId || `nullSessionId-${Math.random() * 1000}`,
|
||||
@@ -183,7 +213,11 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState =
|
||||
azimuth: state.ui?.lightConfig?.azimuth
|
||||
},
|
||||
explodeFactor: state.ui?.explodeFactor || 0,
|
||||
selection: state.ui?.selection || null
|
||||
selection: state.ui?.selection || null,
|
||||
measurement: {
|
||||
enabled: state.ui?.measurement?.enabled ?? false,
|
||||
options: measurementOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import Radio from '~~/src/components/form/Radio.vue'
|
||||
import FormButton from '~~/src/components/form/Button.vue'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { action } from '@storybook/addon-actions'
|
||||
import type { RuleExpression, SubmissionHandler } from 'vee-validate'
|
||||
import { Form } from 'vee-validate'
|
||||
import { userEvent, within } from '@storybook/testing-library'
|
||||
import { wait } from '@speckle/shared'
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import { expect } from '@storybook/jest'
|
||||
import type { VuePlayFunction } from '~~/src/stories/helpers/storybook'
|
||||
import { computed, type ConcreteComponent } from 'vue'
|
||||
import { ArrowRightIcon } from '@heroicons/vue/20/solid'
|
||||
|
||||
export default {
|
||||
component: Radio,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'A radio, integrated with vee-validate for validation. Feed in rules through the `rules` prop. A radio can exist on its own or as part of a group. Radios are grouped if they have the same name and have a parent form. The value structure differs between grouped and ungrouped radios. If a radio is grouped, its v-model value will be an array of all values of all checked radios in the group. Otherwise, its v-model value will either be its value if its checked or undefined if it isnt.'
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
value: {
|
||||
type: 'string'
|
||||
},
|
||||
rules: {
|
||||
type: 'function'
|
||||
},
|
||||
'update:modelValue': {
|
||||
type: 'function',
|
||||
action: 'v-model'
|
||||
},
|
||||
id: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
} as Meta
|
||||
|
||||
const toggleRadioPlayFunction: VuePlayFunction = async (params) => {
|
||||
const canvas = within(params.canvasElement)
|
||||
const radio = canvas.getByRole('radio') as HTMLInputElement
|
||||
|
||||
userEvent.click(radio)
|
||||
// expect(radio.checked).toBeTruthy()
|
||||
await wait(1000)
|
||||
userEvent.click(radio)
|
||||
// expect(radio.checked).toBeFalsy()
|
||||
}
|
||||
|
||||
const defaultArgs = {
|
||||
name: 'test1',
|
||||
label: 'Example Radio',
|
||||
description: 'Some help description here',
|
||||
showRequired: false,
|
||||
validateOnMount: false,
|
||||
inlineDescription: false,
|
||||
value: 'test1' as string | true,
|
||||
disabled: false,
|
||||
modelValue: undefined as Optional<string | true>,
|
||||
rules: undefined as Optional<RuleExpression<any>[]>,
|
||||
icon: undefined as Optional<ConcreteComponent>
|
||||
}
|
||||
|
||||
export const Default: StoryObj<typeof defaultArgs> = {
|
||||
play: toggleRadioPlayFunction,
|
||||
render: (args, ctx) => ({
|
||||
components: { Radio },
|
||||
setup() {
|
||||
const vModelAction = action('v-model')
|
||||
const modelValue = computed({
|
||||
get: () => args.modelValue,
|
||||
set: (newVal) => {
|
||||
ctx.updateArgs({ ...args, modelValue: newVal })
|
||||
vModelAction(newVal)
|
||||
}
|
||||
})
|
||||
|
||||
return { args, modelValue }
|
||||
},
|
||||
template: `<Radio v-bind="args" v-model="modelValue" />`
|
||||
}),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
code: `<Radio name="group-name" value="radio-id" v-model="val" />`
|
||||
}
|
||||
}
|
||||
},
|
||||
args: {
|
||||
name: 'test1',
|
||||
label: 'Example Radio',
|
||||
description: 'Some help description here',
|
||||
showRequired: false,
|
||||
validateOnMount: false,
|
||||
inlineDescription: false,
|
||||
value: 'test1',
|
||||
disabled: false,
|
||||
modelValue: undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const Group: StoryObj<typeof defaultArgs> = {
|
||||
render: (args) => ({
|
||||
components: { Radio, Form, FormButton },
|
||||
setup() {
|
||||
const fooModelValueHandler = action('foo@update:modelValue')
|
||||
const barModelValueHandler = action('bar@update:modelValue')
|
||||
const onSubmit: SubmissionHandler = (values) => action('onSubmit')(values)
|
||||
|
||||
return { fooModelValueHandler, barModelValueHandler, onSubmit, args }
|
||||
},
|
||||
template: `
|
||||
<Form @submit="onSubmit">
|
||||
<Radio name="group1" value="foo" label="foo" @update:modelValue="fooModelValueHandler" alt="foo"/>
|
||||
<Radio name="group1" value="bar" label="bar" @update:modelValue="barModelValueHandler" alt="bar"/>
|
||||
<FormButton submit class="mt-4">Submit</FormButton>
|
||||
</Form>
|
||||
`
|
||||
}),
|
||||
play: async (params) => {
|
||||
const smallDelay = 500
|
||||
const bigDelay = 1000
|
||||
|
||||
const canvas = within(params.canvasElement)
|
||||
|
||||
const fooRadio = canvas.getByAltText('foo')
|
||||
const barRadio = canvas.getByAltText('bar')
|
||||
const button = canvas.getByRole('button')
|
||||
|
||||
userEvent.click(fooRadio)
|
||||
await wait(smallDelay)
|
||||
userEvent.click(button)
|
||||
|
||||
await wait(bigDelay)
|
||||
|
||||
userEvent.click(barRadio)
|
||||
await wait(smallDelay)
|
||||
userEvent.click(button)
|
||||
|
||||
await wait(bigDelay)
|
||||
|
||||
userEvent.click(barRadio)
|
||||
await wait(smallDelay)
|
||||
userEvent.click(barRadio)
|
||||
await wait(smallDelay)
|
||||
userEvent.click(button)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Radios with the same name are part of a group and on form submit the value will be an array of all selected values. Check actions of this story for an example!'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const InlineDescription: StoryObj<typeof defaultArgs> = {
|
||||
...Default,
|
||||
args: {
|
||||
name: 'inline1',
|
||||
value: 'inline1-value',
|
||||
inlineDescription: true,
|
||||
label: 'Example radio',
|
||||
description: 'This is an inline description'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithIcon: StoryObj<typeof defaultArgs> = {
|
||||
...Default,
|
||||
args: {
|
||||
name: 'withIcon',
|
||||
label: 'Example radio with Icon',
|
||||
icon: ArrowRightIcon
|
||||
}
|
||||
}
|
||||
|
||||
export const Disabled: StoryObj<typeof defaultArgs> = {
|
||||
...Default,
|
||||
play: (params) => {
|
||||
const canvas = within(params.canvasElement)
|
||||
const radio = canvas.getByRole('radio')
|
||||
|
||||
const isChecked = (radio as HTMLInputElement).checked
|
||||
|
||||
// click and assert that radio checked state hasn't changed
|
||||
userEvent.click(radio)
|
||||
|
||||
const newIsChecked = (radio as HTMLInputElement).checked
|
||||
expect(isChecked).toBe(newIsChecked)
|
||||
},
|
||||
args: {
|
||||
name: 'disabled1',
|
||||
value: 'disabled1-value',
|
||||
label: 'Disabled radio',
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
|
||||
export const Required: StoryObj<typeof defaultArgs> = {
|
||||
...Default,
|
||||
args: {
|
||||
name: 'required1',
|
||||
value: 'required1-value',
|
||||
label: 'Required radio',
|
||||
showRequired: true,
|
||||
rules: [(val: string | string[]) => (val ? true : 'This field is required')],
|
||||
validateOnMount: true
|
||||
}
|
||||
}
|
||||
|
||||
export const Single: StoryObj<typeof defaultArgs> = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
label: 'Single radio',
|
||||
name: 'single1',
|
||||
value: true
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Set `value` to `true` for non-grouped radios. That way v-model will be `undefined` if unchecked or `true` if checked.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex gap-2 mb-2 last:mb-0"
|
||||
:class="description ? 'items-start' : 'items-center'"
|
||||
>
|
||||
<div class="flex h-6 items-center">
|
||||
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -->
|
||||
<input
|
||||
:id="finalId"
|
||||
:checked="coreChecked"
|
||||
:aria-describedby="descriptionId"
|
||||
:name="name"
|
||||
:disabled="disabled"
|
||||
:value="radioValue"
|
||||
type="radio"
|
||||
class="h-4 w-4 rounded-full text-primary focus:ring-primary bg-foundation disabled:cursor-not-allowed disabled:bg-disabled disabled:text-disabled-2"
|
||||
:class="computedClasses"
|
||||
v-bind="$attrs"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="icon" class="text-sm">
|
||||
<component :is="icon" class="h-10 w-10"></component>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<label :for="finalId" class="text-foreground" :class="{ 'sr-only': hideLabel }">
|
||||
<span>{{ title }}</span>
|
||||
<span v-if="showRequired" class="text-danger ml-1">*</span>
|
||||
</label>
|
||||
<p v-if="descriptionText" :id="descriptionId" :class="descriptionClasses">
|
||||
{{ descriptionText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
import { useField } from 'vee-validate'
|
||||
import type { RuleExpression } from 'vee-validate'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { PropType, ConcreteComponent } from 'vue'
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
/**
|
||||
* Troubleshooting:
|
||||
* - If clicking on the radio doesn't do anything, check if any of its ancestor elements
|
||||
* have a @click.prevent on them anywhere.
|
||||
* - If you're not using the radio in a group, it's suggested that you set :value="true",
|
||||
* so that a v-model attached to the radio will be either 'true' or 'undefined' depending on the
|
||||
* checked state
|
||||
*/
|
||||
|
||||
type ValueType = Optional<string | true> | string[]
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Input name/id. In a radio group, all radios must have the same name and different values.
|
||||
*/
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/**
|
||||
* Whether the input is disabled
|
||||
*/
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* Set label text
|
||||
*/
|
||||
label: {
|
||||
type: String as PropType<Optional<string>>,
|
||||
default: undefined
|
||||
},
|
||||
/**
|
||||
* Help text
|
||||
*/
|
||||
description: {
|
||||
type: String as PropType<Optional<string>>,
|
||||
default: undefined
|
||||
},
|
||||
/**
|
||||
* Whether to inline the help description
|
||||
*/
|
||||
inlineDescription: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* Optional Icon
|
||||
*/
|
||||
icon: {
|
||||
type: Object as PropType<ConcreteComponent>,
|
||||
default: undefined
|
||||
},
|
||||
/**
|
||||
* vee-validate validation rules
|
||||
*/
|
||||
rules: {
|
||||
type: [String, Object, Function, Array] as PropType<RuleExpression<ValueType>>,
|
||||
default: undefined
|
||||
},
|
||||
/**
|
||||
* vee-validate validation() on component mount
|
||||
*/
|
||||
validateOnMount: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* Whether to show the red "required" asterisk
|
||||
*/
|
||||
showRequired: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* Radio group's value
|
||||
*/
|
||||
modelValue: {
|
||||
type: [String, Boolean] as PropType<ValueType | false>,
|
||||
default: undefined
|
||||
},
|
||||
/**
|
||||
* Radio's own value. If it is checked, modelValue will include this value (amongst any other checked values from the same group).
|
||||
* If not set will default to 'name' value.
|
||||
*/
|
||||
value: {
|
||||
type: [String, Boolean] as PropType<Optional<string | true>>,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* HTML ID to use, must be globally unique. If not specified, a random ID will be generated. One is necessary to properly associate the label and radio.
|
||||
*/
|
||||
id: {
|
||||
type: String as PropType<Optional<string>>,
|
||||
default: undefined
|
||||
},
|
||||
hideLabel: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const generateRandomId = (prefix: string) => `${prefix}-${nanoid()}`
|
||||
|
||||
defineEmits<{
|
||||
(e: 'update:modelValue', val: ValueType): void
|
||||
}>()
|
||||
|
||||
const radioValue = computed(() => props.value || props.name)
|
||||
|
||||
const {
|
||||
checked: coreChecked,
|
||||
errorMessage,
|
||||
handleChange,
|
||||
value: coreValue
|
||||
} = useField<ValueType>(props.name, props.rules, {
|
||||
validateOnMount: props.validateOnMount,
|
||||
type: 'radio',
|
||||
checkedValue: radioValue,
|
||||
initialValue: props.modelValue || undefined
|
||||
})
|
||||
|
||||
const title = computed(() => props.label || props.name)
|
||||
|
||||
const computedClasses = computed((): string => {
|
||||
return errorMessage.value ? 'border-danger-lighter' : 'border-foreground-4 '
|
||||
})
|
||||
|
||||
const descriptionText = computed(() => props.description || errorMessage.value)
|
||||
const descriptionId = computed(() => `${props.name}-description`)
|
||||
const descriptionClasses = computed((): string => {
|
||||
const classParts: string[] = []
|
||||
|
||||
if (props.inlineDescription) {
|
||||
classParts.push('inline ml-2')
|
||||
} else {
|
||||
classParts.push('block')
|
||||
}
|
||||
|
||||
if (errorMessage.value) {
|
||||
classParts.push('text-danger')
|
||||
} else {
|
||||
classParts.push('text-foreground-2')
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const implicitId = ref<Optional<string>>(generateRandomId('radio'))
|
||||
const finalId = computed(() => props.id || implicitId.value)
|
||||
|
||||
const onChange = (e: unknown) => {
|
||||
if (props.disabled) return
|
||||
handleChange(e)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bugfix for strange issue where radio appears checked even tho it shouldnt be.
|
||||
* It's not clear why this happens, but for some reason coreValue.value shows that the radio
|
||||
* is checked, even tho props.modelValue is undefined.
|
||||
*/
|
||||
onMounted(() => {
|
||||
const newModelValue = props.modelValue
|
||||
const newCoreValue = coreValue.value
|
||||
|
||||
const shouldBeChecked = Array.isArray(newModelValue)
|
||||
? newModelValue.includes(props.value as any)
|
||||
: newModelValue === props.value
|
||||
|
||||
const isCoreChecked = Array.isArray(newCoreValue)
|
||||
? newCoreValue.includes(props.value as any)
|
||||
: newCoreValue === props.value
|
||||
|
||||
if (shouldBeChecked !== isCoreChecked) {
|
||||
handleChange(newModelValue)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -77,6 +77,7 @@
|
||||
>
|
||||
<Teleport to="body" :disabled="!mountMenuOnBody">
|
||||
<ListboxOptions
|
||||
ref="menuEl"
|
||||
:class="listboxOptionsClasses"
|
||||
:style="listboxOptionsStyle"
|
||||
@focus="searchInput?.focus()"
|
||||
@@ -207,7 +208,7 @@ import type { RuleExpression } from 'vee-validate'
|
||||
import { nanoid } from 'nanoid'
|
||||
import CommonLoadingBar from '~~/src/components/common/loading/Bar.vue'
|
||||
import { directive as vTippy } from 'vue-tippy'
|
||||
import { useElementBounding, useMounted } from '@vueuse/core'
|
||||
import { useElementBounding, useMounted, useIntersectionObserver } from '@vueuse/core'
|
||||
|
||||
type ButtonStyle = 'base' | 'simple' | 'tinted'
|
||||
type ValueType = SingleItem | SingleItem[] | undefined
|
||||
@@ -398,6 +399,7 @@ const { value, errorMessage: error } = useField<ValueType>(props.name, props.rul
|
||||
const isMounted = useMounted()
|
||||
|
||||
const searchInput = ref(null as Nullable<HTMLInputElement>)
|
||||
const menuEl = ref(null as Nullable<{ el: Nullable<HTMLElement> }>)
|
||||
const listboxButton = ref(null as Nullable<{ el: Nullable<HTMLButtonElement> }>)
|
||||
const searchValue = ref('')
|
||||
const currentItems = ref([]) as Ref<SingleItem[]>
|
||||
@@ -410,6 +412,15 @@ const listboxButtonBounding = useElementBounding(
|
||||
{ windowResize: true, windowScroll: true, immediate: true }
|
||||
)
|
||||
|
||||
useIntersectionObserver(
|
||||
computed(() => menuEl.value?.el),
|
||||
([{ isIntersecting }]) => {
|
||||
if (isIntersecting && props.mountMenuOnBody) {
|
||||
listboxButtonBounding.update()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const title = computed(() => unref(props.label) || unref(props.name))
|
||||
const errorMessage = computed(() => {
|
||||
const base = error.value
|
||||
|
||||
@@ -16,6 +16,7 @@ import CommonStepsNumber from '~~/src/components/common/steps/Number.vue'
|
||||
import CommonStepsBullet from '~~/src/components/common/steps/Bullet.vue'
|
||||
import FormCardButton from '~~/src/components/form/CardButton.vue'
|
||||
import FormCheckbox from '~~/src/components/form/Checkbox.vue'
|
||||
import FormRadio from '~~/src/components/form/Radio.vue'
|
||||
import FormTextArea from '~~/src/components/form/TextArea.vue'
|
||||
import FormTextInput from '~~/src/components/form/TextInput.vue'
|
||||
import * as ValidationHelpers from '~~/src/helpers/common/validation'
|
||||
@@ -95,6 +96,7 @@ export {
|
||||
CommonStepsNumber,
|
||||
FormCardButton,
|
||||
FormCheckbox,
|
||||
FormRadio,
|
||||
FormTextArea,
|
||||
FormTextInput,
|
||||
FormSwitch,
|
||||
|
||||
Reference in New Issue
Block a user