Merge branch 'main' of github.com:specklesystems/speckle-server into gergo/web-2124-set-up-email-notifications-for-trial-expiration
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageReference Include="Speckle.WebIfc.Importer" Version="0.0.5" />
|
||||
<PackageReference Include="Speckle.WebIfc.Importer" Version="0.0.7" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<CommonBadge :color-classes="[runStatusClasses(run), 'shrink-0 grow-0'].join(' ')">
|
||||
<CommonBadge
|
||||
:color-classes="
|
||||
[runStatusClasses(run), 'shrink-0 grow-0 text-foreground'].join(' ')
|
||||
"
|
||||
>
|
||||
{{ run.status.toUpperCase() }}
|
||||
</CommonBadge>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:disabled-item-tooltip="
|
||||
!allowGuest ? 'The Guest role isn\'t enabled on the server' : ''
|
||||
"
|
||||
name="serverRoles"
|
||||
:name="name ?? 'serverRoles'"
|
||||
label="Role"
|
||||
:show-label="showLabel"
|
||||
class="min-w-[110px]"
|
||||
@@ -75,7 +75,8 @@ const props = defineProps({
|
||||
allowAdmin: Boolean,
|
||||
allowArchived: Boolean,
|
||||
fullyControlValue: Boolean,
|
||||
showLabel: Boolean
|
||||
showLabel: Boolean,
|
||||
name: String
|
||||
})
|
||||
|
||||
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 7H6L3 15V17"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16 7H18L21 15V17"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 16H14"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 16.5C14 17.4283 14.3687 18.3185 15.0251 18.9749C15.6815 19.6313 16.5717 20 17.5 20C18.4283 20 19.3185 19.6313 19.9749 18.9749C20.6313 18.3185 21 17.4283 21 16.5C21 15.5717 20.6313 14.6815 19.9749 14.0251C19.3185 13.3687 18.4283 13 17.5 13C16.5717 13 15.6815 13.3687 15.0251 14.0251C14.3687 14.6815 14 15.5717 14 16.5Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 16.5C3 16.9596 3.09053 17.4148 3.26642 17.8394C3.44231 18.264 3.70012 18.6499 4.02513 18.9749C4.35013 19.2999 4.73597 19.5577 5.16061 19.7336C5.58525 19.9095 6.04037 20 6.5 20C6.95963 20 7.41475 19.9095 7.83939 19.7336C8.26403 19.5577 8.64987 19.2999 8.97487 18.9749C9.29988 18.6499 9.55769 18.264 9.73358 17.8394C9.90947 17.4148 10 16.9596 10 16.5C10 16.0404 9.90947 15.5852 9.73358 15.1606C9.55769 14.736 9.29988 14.3501 8.97487 14.0251C8.64987 13.7001 8.26403 13.4423 7.83939 13.2664C7.41475 13.0905 6.95963 13 6.5 13C6.04037 13 5.58525 13.0905 5.16061 13.2664C4.73597 13.4423 4.35013 13.7001 4.02513 14.0251C3.70012 14.3501 3.44231 14.736 3.26642 15.1606C3.09053 15.5852 3 16.0404 3 16.5Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -124,7 +124,7 @@
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
<SettingsServerUserInviteDialog v-model:open="showInviteDialog" />
|
||||
<InviteDialogServer v-model:open="showInviteDialog" />
|
||||
<SettingsDialog
|
||||
v-model:open="showSettingsDialog"
|
||||
v-model:target-menu-item="settingsDialogTarget"
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<LayoutDialog v-model:open="isOpen" max-width="md" :buttons="dialogButtons">
|
||||
<template #header>Invite to Speckle</template>
|
||||
<form @submit="onSubmit">
|
||||
<div class="flex flex-col gap-y-5 text-foreground">
|
||||
<div v-for="(item, index) in fields" :key="item.key" class="flex gap-x-3">
|
||||
<div class="flex flex-col gap-y-3 flex-1">
|
||||
<hr v-if="index !== 0" class="border-outline-3" />
|
||||
<div class="flex flex-row gap-x-3 items-center">
|
||||
<div class="flex-1">
|
||||
<FormTextInput
|
||||
v-model="item.value.email"
|
||||
:name="`email-${item.key}`"
|
||||
color="foundation"
|
||||
placeholder="Email address"
|
||||
show-clear
|
||||
full-width
|
||||
use-label-in-errors
|
||||
show-label
|
||||
label="Email"
|
||||
:rules="[isEmail]"
|
||||
/>
|
||||
</div>
|
||||
<FormSelectServerRoles
|
||||
v-if="allowServerRoleSelect"
|
||||
v-model="item.value.serverRole"
|
||||
label="Select role"
|
||||
:name="`role-${item.key}`"
|
||||
class="sm:w-48"
|
||||
show-label
|
||||
:disabled="anyMutationsLoading"
|
||||
:allow-guest="isGuestMode"
|
||||
:allow-admin="isAdmin"
|
||||
mount-menu-on-body
|
||||
/>
|
||||
</div>
|
||||
<FormSelectProjects
|
||||
v-model="item.value.project"
|
||||
label="Select project"
|
||||
class="w-full"
|
||||
owned-only
|
||||
show-optional
|
||||
mount-menu-on-body
|
||||
show-label
|
||||
:name="`project-${index}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="relative w-4">
|
||||
<CommonTextLink
|
||||
v-if="fields.length > 1"
|
||||
class="top-10 absolute right-0"
|
||||
:class="{ 'top-7': index === 0 }"
|
||||
@click="removeInviteItem(index)"
|
||||
>
|
||||
<TrashIcon class="h-4 w-4 text-foreground-2" />
|
||||
</CommonTextLink>
|
||||
</div>
|
||||
</div>
|
||||
<FormButton
|
||||
color="subtle"
|
||||
:icon-left="PlusIcon"
|
||||
:disabled="anyMutationsLoading"
|
||||
@click="addInviteItem"
|
||||
>
|
||||
Invite another user
|
||||
</FormButton>
|
||||
</div>
|
||||
</form>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { useMutationLoading } from '@vue/apollo-composable'
|
||||
import { useForm, useFieldArray } from 'vee-validate'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useServerInfo } from '~~/lib/core/composables/server'
|
||||
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
|
||||
import { useInviteUserToServer } from '~~/lib/server/composables/invites'
|
||||
import { PlusIcon, TrashIcon } from '@heroicons/vue/24/outline'
|
||||
import type { InviteServerForm, InviteServerItem } from '~~/lib/invites/helpers/types'
|
||||
import { emptyInviteServerItem } from '~~/lib/invites/helpers/constants'
|
||||
import { isEmail } from '~~/lib/common/helpers/validation'
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const { handleSubmit } = useForm<InviteServerForm>({
|
||||
initialValues: {
|
||||
fields: [
|
||||
{
|
||||
...emptyInviteServerItem
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
const {
|
||||
fields,
|
||||
replace: replaceFields,
|
||||
push: pushInvite,
|
||||
remove: removeInvite
|
||||
} = useFieldArray<InviteServerItem>('fields')
|
||||
const { mutate: inviteUserToServer } = useInviteUserToServer()
|
||||
const inviteUserToProject = useInviteUserToProject()
|
||||
const anyMutationsLoading = useMutationLoading()
|
||||
const { isAdmin } = useActiveUser()
|
||||
const { isGuestMode } = useServerInfo()
|
||||
const mixpanel = useMixpanel()
|
||||
|
||||
const allowServerRoleSelect = computed(() => isAdmin.value || isGuestMode.value)
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline' },
|
||||
onClick: () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Invite',
|
||||
props: {
|
||||
submit: true,
|
||||
disabled: anyMutationsLoading.value
|
||||
},
|
||||
onClick: onSubmit
|
||||
}
|
||||
])
|
||||
|
||||
const addInviteItem = () => {
|
||||
pushInvite({ ...emptyInviteServerItem })
|
||||
}
|
||||
|
||||
const removeInviteItem = (index: number) => {
|
||||
removeInvite(index)
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit(() => {
|
||||
const invites = fields.value.filter((invite) => invite.value.email)
|
||||
|
||||
invites.forEach(async (invite) => {
|
||||
invite.value.project
|
||||
? await inviteUserToProject(invite.value.project.id, [
|
||||
{
|
||||
email: invite.value.email,
|
||||
serverRole: invite.value.serverRole
|
||||
}
|
||||
])
|
||||
: await inviteUserToServer([
|
||||
{
|
||||
email: invite.value.email,
|
||||
serverRole: invite.value.serverRole
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
mixpanel.track('Invite Action', {
|
||||
type: 'server invite',
|
||||
name: 'send',
|
||||
multiple: fields.value.length !== 1,
|
||||
count: fields.value.length,
|
||||
hasProject: !!fields.value.some((invite) => invite.value.project),
|
||||
to: 'email'
|
||||
})
|
||||
|
||||
isOpen.value = false
|
||||
})
|
||||
|
||||
watch(isOpen, (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
replaceFields([
|
||||
{
|
||||
...emptyInviteServerItem
|
||||
}
|
||||
])
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -213,7 +213,7 @@
|
||||
>
|
||||
<template #header>Your first upload</template>
|
||||
</OnboardingDialogFirstSend>
|
||||
<SettingsServerUserInviteDialog
|
||||
<InviteDialogServer
|
||||
v-model:open="showServerInviteDialog"
|
||||
@update:open="(v) => (!v ? markComplete(3) : '')"
|
||||
/>
|
||||
|
||||
@@ -39,9 +39,6 @@ graphql(`
|
||||
workspace {
|
||||
slug
|
||||
}
|
||||
automations(limit: 0) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
|
||||
@@ -7,10 +7,6 @@
|
||||
<strong>delete “{{ project.name }}”</strong>
|
||||
and all its contents, including
|
||||
<strong>{{ project.models.totalCount }} {{ modelText }}</strong>
|
||||
<span v-if="project.automations.totalCount">
|
||||
and
|
||||
<strong>{{ project.automations.totalCount }} {{ automationText }}</strong>
|
||||
</span>
|
||||
<span v-if="project.commentThreads.totalCount">
|
||||
and
|
||||
<strong>{{ project.commentThreads.totalCount }} {{ discussionText }}</strong>
|
||||
@@ -61,9 +57,6 @@ const modelText = computed(() =>
|
||||
const discussionText = computed(() =>
|
||||
props.project.commentThreads.totalCount === 1 ? 'discussion' : 'discussions'
|
||||
)
|
||||
const automationText = computed(() =>
|
||||
props.project.automations.totalCount === 1 ? 'automation' : 'automations'
|
||||
)
|
||||
|
||||
const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
{
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
:user="userToModify"
|
||||
/>
|
||||
|
||||
<SettingsServerUserInviteDialog v-model:open="showInviteDialog" />
|
||||
<InviteDialogServer v-model:open="showInviteDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
@infinite="onInfiniteLoad"
|
||||
/>
|
||||
|
||||
<SettingsServerUserInviteDialog v-model:open="showInviteDialog" />
|
||||
<InviteDialogServer v-model:open="showInviteDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
<template>
|
||||
<LayoutDialog v-model:open="isOpen" max-width="md" :buttons="dialogButtons">
|
||||
<template #header>Get your colleagues in!</template>
|
||||
<form @submit="onSubmit">
|
||||
<div class="flex flex-col gap-y-3 text-foreground mb-4">
|
||||
<p class="text-body-xs">
|
||||
Speckle will send a server invite link to the email(-s) below. You can also
|
||||
add a personal message if you want to. To add multiple e-mails, seperate them
|
||||
with commas.
|
||||
</p>
|
||||
<FormTextInput
|
||||
name="emailsString"
|
||||
color="foundation"
|
||||
label="E-mail"
|
||||
show-label
|
||||
placeholder="example@example.com, example2@example.com"
|
||||
:rules="[isRequired, isOneOrMultipleEmails]"
|
||||
:disabled="anyMutationsLoading"
|
||||
/>
|
||||
<FormSelectServerRoles
|
||||
v-if="allowServerRoleSelect"
|
||||
v-model="serverRole"
|
||||
label="Select server role"
|
||||
class="w-full sm:w-60"
|
||||
show-label
|
||||
:allow-guest="isGuestMode"
|
||||
:allow-admin="isAdmin"
|
||||
mount-menu-on-body
|
||||
/>
|
||||
<FormTextArea
|
||||
name="message"
|
||||
show-optional
|
||||
show-label
|
||||
label="Message"
|
||||
color="foundation"
|
||||
:disabled="anyMutationsLoading"
|
||||
:rules="[isStringOfLength({ maxLength: 1024 })]"
|
||||
placeholder="Write an optional invitation message!"
|
||||
/>
|
||||
<FormSelectProjects
|
||||
v-model="selectedProject"
|
||||
label="Select project to invite to"
|
||||
class="w-full sm:w-60"
|
||||
owned-only
|
||||
show-optional
|
||||
mount-menu-on-body
|
||||
show-label
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Roles } from '@speckle/shared'
|
||||
import type { Optional, ServerRoles } from '@speckle/shared'
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { useMutationLoading } from '@vue/apollo-composable'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import type { FormSelectProjects_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
isRequired,
|
||||
isOneOrMultipleEmails,
|
||||
isStringOfLength
|
||||
} from '~~/lib/common/helpers/validation'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useServerInfo } from '~~/lib/core/composables/server'
|
||||
import { useInviteUserToProject } from '~~/lib/projects/composables/projectManagement'
|
||||
import { useInviteUserToServer } from '~~/lib/server/composables/invites'
|
||||
|
||||
const selectedProject = ref(undefined as Optional<FormSelectProjects_ProjectFragment>)
|
||||
const serverRole = ref<ServerRoles>(Roles.Server.User)
|
||||
|
||||
const { handleSubmit } = useForm<{ message?: string; emailsString: string }>()
|
||||
const { mutate: inviteUserToServer } = useInviteUserToServer()
|
||||
const inviteUserToProject = useInviteUserToProject()
|
||||
const anyMutationsLoading = useMutationLoading()
|
||||
const { isAdmin } = useActiveUser()
|
||||
const { isGuestMode } = useServerInfo()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const allowServerRoleSelect = computed(() => isAdmin.value || isGuestMode.value)
|
||||
|
||||
const mp = useMixpanel()
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
const emails = values.emailsString.split(',').map((i) => i.trim())
|
||||
const project = selectedProject.value
|
||||
|
||||
const success = project
|
||||
? await inviteUserToProject(
|
||||
project.id,
|
||||
emails.map((email) => ({
|
||||
email,
|
||||
serverRole: allowServerRoleSelect.value ? serverRole.value : undefined
|
||||
}))
|
||||
)
|
||||
: await inviteUserToServer(
|
||||
emails.map((email) => ({
|
||||
email,
|
||||
message: values.message,
|
||||
serverRole: allowServerRoleSelect.value ? serverRole.value : undefined
|
||||
}))
|
||||
)
|
||||
if (success) {
|
||||
isOpen.value = false
|
||||
selectedProject.value = undefined
|
||||
mp.track('Invite Action', {
|
||||
type: 'server invite',
|
||||
name: 'send',
|
||||
multiple: emails.length !== 1,
|
||||
count: emails.length,
|
||||
hasProject: !!project,
|
||||
to: 'email'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline' },
|
||||
onClick: () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Send',
|
||||
props: {
|
||||
submit: true,
|
||||
disabled: anyMutationsLoading.value
|
||||
},
|
||||
onClick: onSubmit
|
||||
}
|
||||
])
|
||||
</script>
|
||||
@@ -49,30 +49,38 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-5 pt-4 flex flex-col gap-y-1">
|
||||
<h3 class="text-body-xs text-foreground-2 pb-1">
|
||||
{{
|
||||
statusIsTrial
|
||||
? 'Expected bill'
|
||||
: subscription?.billingInterval === BillingInterval.Yearly
|
||||
? 'Annual bill'
|
||||
: 'Monthly bill'
|
||||
}}
|
||||
</h3>
|
||||
<p class="text-heading-lg text-foreground inline-block">
|
||||
{{ billValue }} per
|
||||
{{
|
||||
subscription?.billingInterval === BillingInterval.Yearly
|
||||
? 'year'
|
||||
: 'month'
|
||||
}}
|
||||
</p>
|
||||
<p class="text-body-xs text-foreground-2 flex gap-x-1 items-center">
|
||||
{{ billDescription }}
|
||||
<InformationCircleIcon
|
||||
v-tippy="billTooltip"
|
||||
class="w-4 h-4 text-foreground cursor-pointer"
|
||||
/>
|
||||
</p>
|
||||
<template v-if="isPurchasablePlan || statusIsTrial">
|
||||
<h3 class="text-body-xs text-foreground-2 pb-1">
|
||||
{{
|
||||
statusIsTrial
|
||||
? 'Expected bill'
|
||||
: subscription?.billingInterval === BillingInterval.Yearly
|
||||
? 'Annual bill'
|
||||
: 'Monthly bill'
|
||||
}}
|
||||
</h3>
|
||||
<p class="text-heading-lg text-foreground inline-block">
|
||||
{{ billValue }} per
|
||||
{{
|
||||
subscription?.billingInterval === BillingInterval.Yearly
|
||||
? 'year'
|
||||
: 'month'
|
||||
}}
|
||||
</p>
|
||||
<p class="text-body-xs text-foreground-2 flex gap-x-1 items-center">
|
||||
{{ billDescription }}
|
||||
<InformationCircleIcon
|
||||
v-tippy="billTooltip"
|
||||
class="w-4 h-4 text-foreground cursor-pointer"
|
||||
/>
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h3 class="text-body-xs text-foreground-2 pb-1">Expected bill</h3>
|
||||
<p class="text-heading-lg text-foreground inline-block">
|
||||
{{ isAcademiaPlan ? 'Free' : 'Not applicable' }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<div class="p-5 pt-4 flex flex-col gap-y-1">
|
||||
<h3 class="text-body-xs text-foreground-2 pb-1">
|
||||
@@ -123,6 +131,7 @@
|
||||
class="pt-4"
|
||||
/>
|
||||
<SettingsWorkspacesBillingPricingTable
|
||||
v-if="isPurchasablePlan || statusIsTrial"
|
||||
:workspace-id="workspaceId"
|
||||
:current-plan="currentPlan"
|
||||
:active-billing-interval="subscription?.billingInterval"
|
||||
@@ -262,6 +271,9 @@ const isActivePlan = computed(
|
||||
currentPlan.value?.status !== WorkspacePlanStatuses.Trial &&
|
||||
currentPlan.value?.status !== WorkspacePlanStatuses.Canceled
|
||||
)
|
||||
const isAcademiaPlan = computed(
|
||||
() => currentPlan.value?.name === WorkspacePlans.Academia
|
||||
)
|
||||
const isPurchasablePlan = computed(() => isPaidPlan(currentPlan.value?.name))
|
||||
const seatPrice = computed(() =>
|
||||
currentPlan.value && subscription.value
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Teleport to="#toast-portal">
|
||||
<GlobalToastRenderer v-model:notification="notification" />
|
||||
<GlobalToastRenderer v-model:notifications="notifications" @dismiss="dismiss" />
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
import { useGlobalToastManager } from '~~/lib/common/composables/toast'
|
||||
import { GlobalToastRenderer } from '@speckle/ui-components'
|
||||
|
||||
const { currentNotification, dismiss } = useGlobalToastManager()
|
||||
const { currentNotifications, dismissAll, dismiss } = useGlobalToastManager()
|
||||
|
||||
const notification = computed({
|
||||
get: () => currentNotification.value,
|
||||
const notifications = computed({
|
||||
get: () => currentNotifications.value,
|
||||
set: (newVal) => {
|
||||
if (!newVal) {
|
||||
dismiss()
|
||||
dismissAll()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,27 +13,27 @@
|
||||
>
|
||||
<!-- Models -->
|
||||
<ViewerControlsButtonToggle
|
||||
v-tippy="isSmallerOrEqualSm ? undefined : modelsShortcut"
|
||||
:active="activeControl === 'models'"
|
||||
@click="toggleActiveControl('models')"
|
||||
v-tippy="getShortcutDisplayText(shortcuts.ToggleModels)"
|
||||
:active="activePanel === 'models'"
|
||||
@click="toggleActivePanel('models')"
|
||||
>
|
||||
<CubeIcon class="h-4 w-4 md:h-5 md:w-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
|
||||
<!-- Explorer -->
|
||||
<ViewerControlsButtonToggle
|
||||
v-tippy="isSmallerOrEqualSm ? undefined : explorerShortcut"
|
||||
:active="activeControl === 'explorer'"
|
||||
@click="toggleActiveControl('explorer')"
|
||||
v-tippy="getShortcutDisplayText(shortcuts.ToggleExplorer)"
|
||||
:active="activePanel === 'explorer'"
|
||||
@click="toggleActivePanel('explorer')"
|
||||
>
|
||||
<IconFileExplorer class="h-4 w-4 md:h-5 md:w-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
|
||||
<!-- Comment threads -->
|
||||
<ViewerControlsButtonToggle
|
||||
v-tippy="isSmallerOrEqualSm ? undefined : discussionsShortcut"
|
||||
:active="activeControl === 'discussions'"
|
||||
@click="toggleActiveControl('discussions')"
|
||||
v-tippy="getShortcutDisplayText(shortcuts.ToggleDiscussions)"
|
||||
:active="activePanel === 'discussions'"
|
||||
@click="toggleActivePanel('discussions')"
|
||||
>
|
||||
<ChatBubbleLeftRightIcon class="h-4 w-4 md:h-5 md:w-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
@@ -42,8 +42,8 @@
|
||||
<ViewerControlsButtonToggle
|
||||
v-if="allAutomationRuns.length !== 0"
|
||||
v-tippy="isSmallerOrEqualSm ? undefined : summary.longSummary"
|
||||
:active="activeControl === 'automate'"
|
||||
@click="toggleActiveControl('automate')"
|
||||
:active="activePanel === 'automate'"
|
||||
@click="toggleActivePanel('automate')"
|
||||
>
|
||||
<!-- <PlayCircleIcon class="h-5 w-5" /> -->
|
||||
<!-- {{allAutomationRuns.length}} -->
|
||||
@@ -58,8 +58,8 @@
|
||||
|
||||
<!-- Measurements -->
|
||||
<ViewerControlsButtonToggle
|
||||
v-tippy="isSmallerOrEqualSm ? undefined : measureShortcut"
|
||||
:active="activeControl === 'measurements'"
|
||||
v-tippy="getShortcutDisplayText(shortcuts.ToggleMeasurements)"
|
||||
:active="activePanel === 'measurements'"
|
||||
@click="toggleMeasurements"
|
||||
>
|
||||
<IconMeasurements class="h-4 w-4 md:h-5 md:w-5" />
|
||||
@@ -67,27 +67,37 @@
|
||||
<div class="w-8 flex gap-2">
|
||||
<div class="md:hidden">
|
||||
<ViewerControlsButtonToggle
|
||||
:active="activeControl === 'mobileOverflow'"
|
||||
@click="toggleActiveControl('mobileOverflow')"
|
||||
:active="activePanel === 'mobileOverflow'"
|
||||
@click="toggleActivePanel('mobileOverflow')"
|
||||
>
|
||||
<ChevronDoubleRightIcon
|
||||
class="h-4 w-4 md:h-5 md:w-5 transition"
|
||||
:class="activeControl === 'mobileOverflow' ? 'rotate-180' : ''"
|
||||
:class="activePanel === 'mobileOverflow' ? 'rotate-180' : ''"
|
||||
/>
|
||||
</ViewerControlsButtonToggle>
|
||||
</div>
|
||||
<div
|
||||
class="-mt-28 md:mt-0 bg-foundation md:bg-transparent md:gap-2 shadow-md md:shadow-none flex flex-col rounded-lg transition-all *:shadow-none *:py-0 *:md:shadow-md *:md:py-2"
|
||||
:class="[
|
||||
activeControl === 'mobileOverflow' ? '' : '-translate-x-24 md:translate-x-0'
|
||||
activePanel === 'mobileOverflow' ? '' : '-translate-x-24 md:translate-x-0'
|
||||
]"
|
||||
>
|
||||
<ViewerControlsButtonGroup>
|
||||
<!-- View Modes -->
|
||||
<ViewerViewModesMenu
|
||||
:open="viewModesOpen"
|
||||
@force-close-others="activeControl = 'none'"
|
||||
@update:open="(value: boolean) => toggleActiveControl(value ? 'viewModes' : 'none')"
|
||||
/>
|
||||
<!-- Views -->
|
||||
<ViewerViewsMenu v-tippy="isSmallerOrEqualSm ? undefined : 'Views'" />
|
||||
<ViewerViewsMenu
|
||||
:open="viewsOpen"
|
||||
@force-close-others="activeControl = 'none'"
|
||||
@update:open="(value: boolean) => toggleActiveControl(value ? 'views' : 'none')"
|
||||
/>
|
||||
<!-- Zoom extents -->
|
||||
<ViewerControlsButtonToggle
|
||||
v-tippy="isSmallerOrEqualSm ? undefined : zoomExtentsShortcut"
|
||||
v-tippy="getShortcutDisplayText(shortcuts.ZoomExtentsOrSelection)"
|
||||
flat
|
||||
@click="trackAndzoomExtentsOrSelection()"
|
||||
>
|
||||
@@ -96,14 +106,15 @@
|
||||
|
||||
<!-- Sun and lights -->
|
||||
<ViewerSunMenu
|
||||
v-tippy="isSmallerOrEqualSm ? undefined : 'Light controls'"
|
||||
:open="activeControl === 'sun'"
|
||||
@update:open="(value: boolean) => toggleActiveControl(value ? 'sun' : 'none')"
|
||||
/>
|
||||
</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="isSmallerOrEqualSm ? undefined : projectionShortcut"
|
||||
v-tippy="getShortcutDisplayText(shortcuts.ToggleProjection)"
|
||||
flat
|
||||
secondary
|
||||
:active="isOrthoProjection"
|
||||
@@ -115,7 +126,7 @@
|
||||
|
||||
<!-- Section Box -->
|
||||
<ViewerControlsButtonToggle
|
||||
v-tippy="isSmallerOrEqualSm ? undefined : sectionBoxShortcut"
|
||||
v-tippy="getShortcutDisplayText(shortcuts.ToggleSectionBox)"
|
||||
flat
|
||||
secondary
|
||||
:active="isSectionBoxVisible"
|
||||
@@ -125,8 +136,11 @@
|
||||
</ViewerControlsButtonToggle>
|
||||
|
||||
<!-- Explosion -->
|
||||
<ViewerExplodeMenu v-tippy="isSmallerOrEqualSm ? undefined : 'Explode'" />
|
||||
|
||||
<ViewerExplodeMenu
|
||||
:open="explodeOpen"
|
||||
@force-close-others="activeControl = 'none'"
|
||||
@update:open="(value: boolean) => toggleActiveControl(value ? 'explode' : 'none')"
|
||||
/>
|
||||
<!-- Settings -->
|
||||
<ViewerSettingsMenu />
|
||||
</ViewerControlsButtonGroup>
|
||||
@@ -135,9 +149,9 @@
|
||||
<ViewerControlsButtonToggle
|
||||
v-show="isGendoEnabled"
|
||||
v-tippy="'Real time AI rendering powered by Gendo'"
|
||||
:active="activeControl === 'gendo'"
|
||||
:active="activePanel === 'gendo'"
|
||||
class="hover:hue-rotate-30 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-amber-200 via-violet-600 to-sky-900"
|
||||
@click="toggleActiveControl('gendo')"
|
||||
@click="toggleActivePanel('gendo')"
|
||||
>
|
||||
<img
|
||||
src="~/assets/images/gendo/logo.svg"
|
||||
@@ -150,7 +164,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeControl !== 'none'"
|
||||
v-if="activePanel !== 'none'"
|
||||
ref="resizeHandle"
|
||||
class="absolute z-10 max-h-[calc(100dvh-4rem)] w-7 mt-[3.9rem] hidden sm:flex group overflow-hidden items-center rounded-r cursor-ew-resize z-30"
|
||||
:style="`left:${width - 2}px; height:${height ? height - 10 : 0}px`"
|
||||
@@ -170,54 +184,54 @@
|
||||
<div
|
||||
ref="scrollableControlsContainer"
|
||||
:class="`simple-scrollbar absolute z-10 pl-12 pr-2 md:pr-0 md:pl-14 mb-4 max-h-[calc(100dvh-4.5rem)] overflow-y-auto px-[2px] py-[2px] transition ${
|
||||
activeControl !== 'none'
|
||||
activePanel !== 'none'
|
||||
? 'translate-x-0 opacity-100'
|
||||
: '-translate-x-[100%] opacity-0'
|
||||
} ${isEmbedEnabled ? 'mt-1.5' : 'mt-[3.7rem]'}`"
|
||||
:style="`width: ${isMobile ? '100%' : `${width + 4}px`};`"
|
||||
>
|
||||
<div v-if="activeControl.length !== 0 && activeControl === 'measurements'">
|
||||
<div v-if="activePanel === 'measurements'">
|
||||
<KeepAlive>
|
||||
<div><ViewerMeasurementsOptions @close="toggleMeasurements" /></div>
|
||||
</KeepAlive>
|
||||
</div>
|
||||
<div v-show="resourceItems.length !== 0 && activeControl === 'models'">
|
||||
<div v-show="activePanel === 'models'">
|
||||
<KeepAlive>
|
||||
<div>
|
||||
<ViewerResourcesList
|
||||
v-if="!enabled"
|
||||
class="pointer-events-auto"
|
||||
@loaded-more="scrollControlsToBottom"
|
||||
@close="activeControl = 'none'"
|
||||
@close="activePanel = 'none'"
|
||||
/>
|
||||
<ViewerCompareChangesPanel v-else @close="activeControl = 'none'" />
|
||||
<ViewerCompareChangesPanel v-else @close="activePanel = 'none'" />
|
||||
</div>
|
||||
</KeepAlive>
|
||||
</div>
|
||||
|
||||
<div v-show="resourceItems.length !== 0 && activeControl === 'explorer'">
|
||||
<div v-show="resourceItems.length !== 0 && activePanel === 'explorer'">
|
||||
<KeepAlive>
|
||||
<ViewerExplorer class="pointer-events-auto" @close="activeControl = 'none'" />
|
||||
<ViewerExplorer class="pointer-events-auto" @close="activePanel = 'none'" />
|
||||
</KeepAlive>
|
||||
</div>
|
||||
|
||||
<ViewerComments
|
||||
v-if="resourceItems.length !== 0 && activeControl === 'discussions'"
|
||||
v-if="resourceItems.length !== 0 && activePanel === 'discussions'"
|
||||
class="pointer-events-auto"
|
||||
@close="activeControl = 'none'"
|
||||
@close="activePanel = 'none'"
|
||||
/>
|
||||
|
||||
<div v-show="resourceItems.length !== 0 && activeControl === 'automate'">
|
||||
<div v-show="resourceItems.length !== 0 && activePanel === 'automate'">
|
||||
<AutomateViewerPanel
|
||||
:automation-runs="allAutomationRuns"
|
||||
:summary="summary"
|
||||
@close="activeControl = 'none'"
|
||||
@close="activePanel = 'none'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="resourceItems.length !== 0 && activeControl === 'gendo' && isGendoEnabled"
|
||||
v-if="resourceItems.length !== 0 && activePanel === 'gendo' && isGendoEnabled"
|
||||
>
|
||||
<ViewerGendoPanel @close="activeControl = 'none'" />
|
||||
<ViewerGendoPanel @close="activePanel = 'none'" />
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
@@ -258,17 +272,12 @@ import { isNonNullable, type Nullable } from '@speckle/shared'
|
||||
import {
|
||||
useCameraUtilities,
|
||||
useSectionBoxUtilities,
|
||||
useMeasurementUtilities
|
||||
useMeasurementUtilities,
|
||||
useViewerShortcuts
|
||||
} from '~~/lib/viewer/composables/ui'
|
||||
import {
|
||||
onKeyboardShortcut,
|
||||
ModifierKeys,
|
||||
getKeyboardShortcutTitle
|
||||
} from '@speckle/ui-components'
|
||||
import {
|
||||
useInjectedViewerLoadedResources,
|
||||
useInjectedViewerInterfaceState,
|
||||
useInjectedViewerState
|
||||
useInjectedViewerInterfaceState
|
||||
} from '~~/lib/viewer/composables/setup'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
|
||||
@@ -283,17 +292,27 @@ import {
|
||||
import { useFunctionRunsStatusSummary } from '~/lib/automate/composables/runStatus'
|
||||
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
|
||||
|
||||
const isGendoEnabled = useIsGendoModuleEnabled()
|
||||
type ActivePanel =
|
||||
| 'none'
|
||||
| 'models'
|
||||
| 'explorer'
|
||||
| 'discussions'
|
||||
| 'automate'
|
||||
| 'measurements'
|
||||
| 'gendo'
|
||||
| 'mobileOverflow'
|
||||
|
||||
enum ViewerKeyboardActions {
|
||||
ToggleModels = 'ToggleModels',
|
||||
ToggleExplorer = 'ToggleExplorer',
|
||||
ToggleDiscussions = 'ToggleDiscussions',
|
||||
ToggleMeasurements = 'ToggleMeasurements',
|
||||
ToggleProjection = 'ToggleProjection',
|
||||
ToggleSectionBox = 'ToggleSectionBox',
|
||||
ZoomExtentsOrSelection = 'ZoomExtentsOrSelection'
|
||||
}
|
||||
type ActiveControl =
|
||||
| 'none'
|
||||
| 'viewModes'
|
||||
| 'views'
|
||||
| 'sun'
|
||||
| 'projection'
|
||||
| 'sectionBox'
|
||||
| 'explode'
|
||||
| 'settings'
|
||||
|
||||
const isGendoEnabled = useIsGendoModuleEnabled()
|
||||
|
||||
const width = ref(360)
|
||||
const scrollableControlsContainer = ref(null as Nullable<HTMLDivElement>)
|
||||
@@ -336,17 +355,6 @@ if (import.meta.client) {
|
||||
})
|
||||
}
|
||||
|
||||
type ActiveControl =
|
||||
| 'none'
|
||||
| 'models'
|
||||
| 'explorer'
|
||||
| 'filters'
|
||||
| 'discussions'
|
||||
| 'automate'
|
||||
| 'measurements'
|
||||
| 'mobileOverflow'
|
||||
| 'gendo'
|
||||
|
||||
const { resourceItems, modelsAndVersionIds } = useInjectedViewerLoadedResources()
|
||||
const {
|
||||
resetSectionBox,
|
||||
@@ -364,8 +372,11 @@ const {
|
||||
toggleProjection,
|
||||
camera: { isOrthoProjection }
|
||||
} = useCameraUtilities()
|
||||
const { registerShortcuts, getShortcutDisplayText, shortcuts } = useViewerShortcuts()
|
||||
|
||||
const { ui } = useInjectedViewerState()
|
||||
const {
|
||||
diff: { enabled }
|
||||
} = useInjectedViewerInterfaceState()
|
||||
|
||||
const breakpoints = useBreakpoints(TailwindBreakpoints)
|
||||
const isMobile = breakpoints.smaller('sm')
|
||||
@@ -389,100 +400,47 @@ const { summary } = useFunctionRunsStatusSummary({
|
||||
|
||||
const openAddModel = ref(false)
|
||||
|
||||
const activeControl = ref<ActiveControl>('models')
|
||||
|
||||
const {
|
||||
diff: { enabled }
|
||||
} = useInjectedViewerInterfaceState()
|
||||
|
||||
const map: Record<ViewerKeyboardActions, [ModifierKeys[], string]> = {
|
||||
[ViewerKeyboardActions.ToggleModels]: [[ModifierKeys.Shift], 'M'],
|
||||
[ViewerKeyboardActions.ToggleExplorer]: [[ModifierKeys.Shift], 'E'],
|
||||
[ViewerKeyboardActions.ToggleDiscussions]: [[ModifierKeys.Shift], 'T'],
|
||||
[ViewerKeyboardActions.ToggleMeasurements]: [[ModifierKeys.Shift], 'R'],
|
||||
[ViewerKeyboardActions.ToggleProjection]: [[ModifierKeys.Shift], 'P'],
|
||||
[ViewerKeyboardActions.ToggleSectionBox]: [[ModifierKeys.Shift], 'B'],
|
||||
[ViewerKeyboardActions.ZoomExtentsOrSelection]: [[ModifierKeys.Shift], 'space']
|
||||
}
|
||||
|
||||
const getShortcutTitle = (action: ViewerKeyboardActions) =>
|
||||
`(${getKeyboardShortcutTitle([...map[action][0], map[action][1]])})`
|
||||
|
||||
const modelsShortcut = ref(
|
||||
`Models ${getShortcutTitle(ViewerKeyboardActions.ToggleModels)}`
|
||||
)
|
||||
const explorerShortcut = ref(
|
||||
`Scene explorer ${getShortcutTitle(ViewerKeyboardActions.ToggleExplorer)}`
|
||||
)
|
||||
const discussionsShortcut = ref(
|
||||
`Discussions ${getShortcutTitle(ViewerKeyboardActions.ToggleDiscussions)}`
|
||||
)
|
||||
const zoomExtentsShortcut = ref(
|
||||
`Fit to screen ${getShortcutTitle(ViewerKeyboardActions.ZoomExtentsOrSelection)}`
|
||||
)
|
||||
const projectionShortcut = ref(
|
||||
`Projection ${getShortcutTitle(ViewerKeyboardActions.ToggleProjection)}`
|
||||
)
|
||||
const sectionBoxShortcut = ref(
|
||||
`Section box ${getShortcutTitle(ViewerKeyboardActions.ToggleSectionBox)}`
|
||||
)
|
||||
const measureShortcut = ref(
|
||||
`Measure mode ${getShortcutTitle(ViewerKeyboardActions.ToggleMeasurements)}`
|
||||
)
|
||||
|
||||
const isTypingComment = computed(() => {
|
||||
const isNewThreadEditorOpen = ui.threads.openThread.newThreadEditor.value
|
||||
const isExistingThreadEditorOpen = !!ui.threads.openThread.thread.value
|
||||
return isNewThreadEditorOpen || isExistingThreadEditorOpen
|
||||
})
|
||||
|
||||
const handleKeyboardAction = (action: ViewerKeyboardActions) => {
|
||||
if (isTypingComment.value) {
|
||||
return
|
||||
}
|
||||
switch (action) {
|
||||
case ViewerKeyboardActions.ToggleModels:
|
||||
toggleActiveControl('models')
|
||||
break
|
||||
case ViewerKeyboardActions.ToggleExplorer:
|
||||
toggleActiveControl('explorer')
|
||||
break
|
||||
case ViewerKeyboardActions.ToggleDiscussions:
|
||||
toggleActiveControl('discussions')
|
||||
break
|
||||
case ViewerKeyboardActions.ToggleMeasurements:
|
||||
toggleMeasurements()
|
||||
break
|
||||
case ViewerKeyboardActions.ToggleProjection:
|
||||
trackAndtoggleProjection()
|
||||
break
|
||||
case ViewerKeyboardActions.ToggleSectionBox:
|
||||
toggleSectionBox()
|
||||
break
|
||||
case ViewerKeyboardActions.ZoomExtentsOrSelection:
|
||||
trackAndzoomExtentsOrSelection()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
Object.entries(map).forEach(([actionKey, [modifiers, key]]) => {
|
||||
const action = actionKey as ViewerKeyboardActions
|
||||
onKeyboardShortcut(modifiers, key, () => handleKeyboardAction(action))
|
||||
})
|
||||
const activeControl = ref<ActiveControl>('none')
|
||||
const activePanel = ref<ActivePanel>('none')
|
||||
|
||||
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
|
||||
|
||||
const toggleActiveControl = (control: ActiveControl) => {
|
||||
const isMeasurementsActive = activeControl.value === 'measurements'
|
||||
if (isMeasurementsActive && control !== 'measurements') {
|
||||
const toggleActivePanel = (panel: ActivePanel) => {
|
||||
const isMeasurementsActive = activePanel.value === 'measurements'
|
||||
if (isMeasurementsActive && panel !== 'measurements') {
|
||||
enableMeasurements(false)
|
||||
}
|
||||
|
||||
// Special handling for mobile overflow
|
||||
if (isSmallerOrEqualSm.value && panel === 'mobileOverflow') {
|
||||
activePanel.value =
|
||||
activePanel.value === 'mobileOverflow' ? 'none' : 'mobileOverflow'
|
||||
} else {
|
||||
activePanel.value = activePanel.value === panel ? 'none' : panel
|
||||
}
|
||||
}
|
||||
|
||||
const toggleActiveControl = (control: ActiveControl) => {
|
||||
activeControl.value = activeControl.value === control ? 'none' : control
|
||||
}
|
||||
|
||||
registerShortcuts({
|
||||
ToggleModels: () => toggleActivePanel('models'),
|
||||
ToggleExplorer: () => toggleActivePanel('explorer'),
|
||||
ToggleDiscussions: () => toggleActivePanel('discussions'),
|
||||
ToggleMeasurements: () => toggleMeasurements(),
|
||||
ToggleProjection: () => trackAndtoggleProjection(),
|
||||
ToggleSectionBox: () => toggleSectionBox(),
|
||||
ZoomExtentsOrSelection: () => trackAndzoomExtentsOrSelection()
|
||||
})
|
||||
|
||||
const mp = useMixpanel()
|
||||
watch(activeControl, (newVal) => {
|
||||
mp.track('Viewer Action', { type: 'action', name: 'controls-toggle', action: newVal })
|
||||
mp.track('Viewer Action', {
|
||||
type: 'action',
|
||||
name: 'controls-toggle',
|
||||
action: newVal
|
||||
})
|
||||
})
|
||||
|
||||
const trackAndzoomExtentsOrSelection = () => {
|
||||
@@ -506,30 +464,32 @@ const scrollControlsToBottom = () => {
|
||||
}
|
||||
|
||||
const toggleMeasurements = () => {
|
||||
const isMeasurementsActive = activeControl.value === 'measurements'
|
||||
const isMeasurementsActive = activePanel.value === 'measurements'
|
||||
enableMeasurements(!isMeasurementsActive)
|
||||
activeControl.value = isMeasurementsActive ? 'none' : 'measurements'
|
||||
activePanel.value = isMeasurementsActive ? 'none' : 'measurements'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
activeControl.value = isSmallerOrEqualSm.value ? 'none' : 'models'
|
||||
})
|
||||
|
||||
onKeyStroke('Escape', () => {
|
||||
const isActiveMeasurement = getActiveMeasurement()
|
||||
|
||||
if (isActiveMeasurement) {
|
||||
removeMeasurement()
|
||||
} else {
|
||||
if (activeControl.value === 'measurements') {
|
||||
if (activePanel.value === 'measurements') {
|
||||
toggleMeasurements()
|
||||
}
|
||||
activePanel.value = 'none'
|
||||
activeControl.value = 'none'
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Set initial panel state after component is mounted
|
||||
activePanel.value = isSmallerOrEqualSm.value ? 'none' : 'models'
|
||||
})
|
||||
|
||||
watch(isSmallerOrEqualSm, (newVal) => {
|
||||
activeControl.value = newVal ? 'none' : 'models'
|
||||
activePanel.value = newVal ? 'none' : 'models'
|
||||
})
|
||||
|
||||
watch(isSectionBoxEnabled, (val) => {
|
||||
@@ -547,4 +507,25 @@ watch(isSectionBoxVisible, (val) => {
|
||||
status: val
|
||||
})
|
||||
})
|
||||
|
||||
const viewModesOpen = computed({
|
||||
get: () => activeControl.value === 'viewModes',
|
||||
set: (value) => {
|
||||
activeControl.value = value ? 'viewModes' : 'none'
|
||||
}
|
||||
})
|
||||
|
||||
const viewsOpen = computed({
|
||||
get: () => activeControl.value === 'views',
|
||||
set: (value) => {
|
||||
activeControl.value = value ? 'views' : 'none'
|
||||
}
|
||||
})
|
||||
|
||||
const explodeOpen = computed({
|
||||
get: () => activeControl.value === 'explode',
|
||||
set: (value) => {
|
||||
activeControl.value = value ? 'explode' : 'none'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,53 +1,46 @@
|
||||
<template>
|
||||
<div class="relative z-30">
|
||||
<Popover as="div" class="relative z-30">
|
||||
<PopoverButton v-slot="{ open }" as="template">
|
||||
<ViewerControlsButtonToggle flat secondary :active="open || isActive">
|
||||
<!-- <ChevronUpDownIcon class="w-5 h-5 rotate-45" /> -->
|
||||
<IconExplode class="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
enter-active-class="transform ease-out duration-300 transition"
|
||||
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<PopoverPanel
|
||||
class="absolute translate-x-0 left-12 top-0 p-2 bg-foundation max-h-64 simple-scrollbar overflow-y-auto outline outline-2 outline-primary-muted rounded-lg shadow-lg overflow-hidden flex flex-col space-y-2"
|
||||
>
|
||||
<div class="flex items-center space-x-1">
|
||||
<input
|
||||
id="intensity"
|
||||
v-model="explodeFactor"
|
||||
class="w-24 sm:w-32 h-2 mr-2"
|
||||
type="range"
|
||||
name="intensity"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
/>
|
||||
<label class="text-body-xs text-foreground-2" for="intensity">
|
||||
Intensity
|
||||
</label>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
</div>
|
||||
<ViewerMenu
|
||||
v-model:open="open"
|
||||
v-tippy="isSmallerOrEqualSm ? undefined : 'Explode'"
|
||||
tooltip="Explode model"
|
||||
>
|
||||
<template #trigger-icon>
|
||||
<IconExplode
|
||||
class="h-4 w-4 sm:h-5 sm:w-5"
|
||||
:class="{ 'text-foreground-2': !isActive }"
|
||||
/>
|
||||
</template>
|
||||
<div class="w-56 p-2 flex flex-col space-y-2">
|
||||
<div class="flex items-center space-x-1">
|
||||
<input
|
||||
id="intensity"
|
||||
v-model="explodeFactor"
|
||||
class="w-24 sm:w-32 h-2 mr-2"
|
||||
type="range"
|
||||
name="intensity"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
/>
|
||||
<label class="text-body-xs text-foreground-2" for="intensity">Intensity</label>
|
||||
</div>
|
||||
</div>
|
||||
</ViewerMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
// import { ChevronUpDownIcon } from '@heroicons/vue/24/outline'
|
||||
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
|
||||
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const {
|
||||
ui: { explodeFactor }
|
||||
} = useInjectedViewerState()
|
||||
|
||||
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
|
||||
|
||||
const isActive = computed(() => {
|
||||
return explodeFactor.value > 0.01
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center justify-between hover:bg-highlight-1 text-foreground w-full h-full text-body-2xs sm:text-body-xs py-1 px-2 rounded-md"
|
||||
:class="{ 'bg-highlight-1': active }"
|
||||
>
|
||||
<div v-if="!hideActiveTick" class="w-5 shrink-0">
|
||||
<IconCheck v-if="active" class="h-4 w-4 text-foreground-2" />
|
||||
</div>
|
||||
<div class="flex-1 text-left">{{ label }}</div>
|
||||
<span v-if="shortcut" class="text-body-2xs text-foreground-2">
|
||||
{{ shortcut }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
active?: boolean
|
||||
hideActiveTick?: boolean
|
||||
shortcut?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div ref="menuWrapper" class="relative z-30">
|
||||
<ViewerControlsButtonToggle
|
||||
v-tippy="tooltip"
|
||||
flat
|
||||
secondary
|
||||
:active="open"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<slot name="trigger-icon" />
|
||||
</ViewerControlsButtonToggle>
|
||||
<div
|
||||
v-if="open"
|
||||
ref="menuContent"
|
||||
v-keyboard-clickable
|
||||
class="absolute left-10 sm:left-12 -top-0 bg-foundation max-h-64 simple-scrollbar overflow-y-auto rounded-lg shadow-md flex flex-col"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
defineProps<{
|
||||
tooltip?: string
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const menuContent = ref<HTMLElement | null>(null)
|
||||
const menuWrapper = ref<HTMLElement | null>(null)
|
||||
|
||||
const toggleMenu = () => {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
onClickOutside(
|
||||
menuContent,
|
||||
(event) => {
|
||||
if (!menuWrapper.value?.contains(event.target as Node)) {
|
||||
open.value = false
|
||||
}
|
||||
},
|
||||
{ ignore: [menuWrapper] }
|
||||
)
|
||||
</script>
|
||||
@@ -1,105 +1,111 @@
|
||||
<template>
|
||||
<div class="relative z-30">
|
||||
<Popover as="div" class="relative z-30">
|
||||
<PopoverButton v-slot="{ open }" as="template">
|
||||
<ViewerControlsButtonToggle flat secondary :active="open">
|
||||
<SunIcon class="w-5 h-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
enter-active-class="transform ease-out duration-300 transition"
|
||||
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<PopoverPanel
|
||||
class="absolute translate-x-0 left-10 sm:left-12 top-2 bg-foundation max-h-64 simple-scrollbar overflow-y-auto outline outline-2 outline-primary-muted rounded-lg shadow-lg overflow-hidden flex flex-col space-y-2"
|
||||
>
|
||||
<div class="p-2 border-b border-outline flex gap-2 items-center">
|
||||
<div class="scale-90">
|
||||
<FormSwitch
|
||||
v-model="sunlightShadows"
|
||||
name="sunShadows"
|
||||
:show-label="false"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-foreground text-body-sm">Sun shadows</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<input
|
||||
id="intensity"
|
||||
v-model="intensity"
|
||||
class="w-24 sm:w-32 h-2 mr-2"
|
||||
type="range"
|
||||
name="intensity"
|
||||
min="1"
|
||||
max="10"
|
||||
step="0.05"
|
||||
/>
|
||||
<label class="text-body-xs text-foreground-2" for="intensity">
|
||||
Intensity
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<input
|
||||
id="elevation"
|
||||
v-model="elevation"
|
||||
class="w-24 sm:w-32 h-2 mr-2"
|
||||
type="range"
|
||||
name="elevation"
|
||||
min="0"
|
||||
:max="Math.PI"
|
||||
step="0.05"
|
||||
/>
|
||||
<label class="text-body-xs text-foreground-2" for="elevation">
|
||||
Elevation
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<input
|
||||
id="azimuth"
|
||||
v-model="azimuth"
|
||||
class="w-24 sm:w-32 h-2 mr-2"
|
||||
type="range"
|
||||
name="azimuth"
|
||||
:min="-Math.PI * 0.5"
|
||||
:max="Math.PI * 0.5"
|
||||
step="0.05"
|
||||
/>
|
||||
<label class="text-body-xs text-foreground-2" for="azimuth">Azimuth</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2 pb-2">
|
||||
<input
|
||||
id="indirect"
|
||||
v-model="indirectLightIntensity"
|
||||
class="w-24 sm:w-32 h-2 mr-2"
|
||||
type="range"
|
||||
name="indirect"
|
||||
min="0"
|
||||
max="5"
|
||||
step="0.05"
|
||||
/>
|
||||
<label class="text-body-xs text-foreground-2" for="indirect">
|
||||
Indirect
|
||||
</label>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
</div>
|
||||
<ViewerMenu
|
||||
v-model:open="open"
|
||||
tooltip="Light controls"
|
||||
:disabled="!isLightingSupported"
|
||||
:disabled-tooltip="'Light controls are only available in Default and Default with Edges view modes'"
|
||||
>
|
||||
<template #trigger-icon>
|
||||
<SunIcon class="w-5 h-5" :class="{ 'text-foreground-3': !isLightingSupported }" />
|
||||
</template>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div v-if="!isLightingSupported" class="-mb-1 p-2 pb-0">
|
||||
<CommonAlert size="xs" color="info">
|
||||
<template #title>
|
||||
<span class="block text-body-2xs">Not available in current view mode.</span>
|
||||
</template>
|
||||
</CommonAlert>
|
||||
</div>
|
||||
<div class="px-2 py-1 border-b border-outline flex gap-2 items-center">
|
||||
<FormSwitch
|
||||
v-model="sunlightShadows"
|
||||
name="sunShadows"
|
||||
:show-label="false"
|
||||
:disabled="!isLightingSupported"
|
||||
/>
|
||||
<span class="text-foreground text-body-xs">Sun shadows</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<input
|
||||
id="intensity"
|
||||
v-model="intensity"
|
||||
class="w-24 sm:w-32 h-2 mr-2"
|
||||
type="range"
|
||||
name="intensity"
|
||||
min="1"
|
||||
max="10"
|
||||
step="0.05"
|
||||
:disabled="!isLightingSupported"
|
||||
/>
|
||||
<label class="text-body-xs text-foreground-2" for="intensity">Intensity</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<input
|
||||
id="elevation"
|
||||
v-model="elevation"
|
||||
class="w-24 sm:w-32 h-2 mr-2"
|
||||
type="range"
|
||||
name="elevation"
|
||||
min="0"
|
||||
:max="Math.PI"
|
||||
step="0.05"
|
||||
:disabled="!isLightingSupported"
|
||||
/>
|
||||
<label class="text-body-xs text-foreground-2" for="elevation">Elevation</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<input
|
||||
id="azimuth"
|
||||
v-model="azimuth"
|
||||
class="w-24 sm:w-32 h-2 mr-2"
|
||||
type="range"
|
||||
name="azimuth"
|
||||
:min="-Math.PI * 0.5"
|
||||
:max="Math.PI * 0.5"
|
||||
step="0.05"
|
||||
:disabled="!isLightingSupported"
|
||||
/>
|
||||
<label class="text-body-xs text-foreground-2" for="azimuth">Azimuth</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2 pb-2">
|
||||
<input
|
||||
id="indirect"
|
||||
v-model="indirectLightIntensity"
|
||||
class="w-24 sm:w-32 h-2 mr-2"
|
||||
type="range"
|
||||
name="indirect"
|
||||
min="0"
|
||||
max="5"
|
||||
step="0.05"
|
||||
:disabled="!isLightingSupported"
|
||||
/>
|
||||
<label class="text-body-xs text-foreground-2" for="indirect">Indirect</label>
|
||||
</div>
|
||||
</div>
|
||||
</ViewerMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
||||
import type { SunLightConfiguration } from '@speckle/viewer'
|
||||
import { ViewMode, type SunLightConfiguration } from '@speckle/viewer'
|
||||
import { SunIcon } from '@heroicons/vue/24/outline'
|
||||
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { FormSwitch } from '@speckle/ui-components'
|
||||
import { useViewModeUtilities } from '~/lib/viewer/composables/ui'
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const mp = useMixpanel()
|
||||
const { currentViewMode } = useViewModeUtilities()
|
||||
|
||||
const isLightingSupported = computed(() => {
|
||||
const supported =
|
||||
currentViewMode.value === ViewMode.DEFAULT ||
|
||||
currentViewMode.value === ViewMode.DEFAULT_EDGES
|
||||
return supported
|
||||
})
|
||||
|
||||
const debounceTrackLightConfigChange = debounce(() => {
|
||||
mp.track('Viewer Action', {
|
||||
type: 'action',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<template>
|
||||
<ViewerMenu v-model:open="open" tooltip="View modes">
|
||||
<template #trigger-icon>
|
||||
<IconViewModes class="h-5 w-5" />
|
||||
</template>
|
||||
<div
|
||||
class="w-56 p-1.5"
|
||||
@mouseenter="cancelCloseTimer"
|
||||
@mouseleave="isManuallyOpened ? undefined : startCloseTimer"
|
||||
@focusin="cancelCloseTimer"
|
||||
@focusout="isManuallyOpened ? undefined : startCloseTimer"
|
||||
>
|
||||
<div v-for="shortcut in viewModeShortcuts" :key="shortcut.name">
|
||||
<ViewerMenuItem
|
||||
:label="shortcut.name"
|
||||
:active="isActiveMode(shortcut.viewMode)"
|
||||
:shortcut="getShortcutDisplayText(shortcut, { hideName: true })"
|
||||
@click="handleViewModeChange(shortcut.viewMode)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ViewerMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { ViewMode } from '@speckle/viewer'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useViewerShortcuts, useViewModeUtilities } from '~~/lib/viewer/composables/ui'
|
||||
import { ViewModeShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { setViewMode, currentViewMode } = useViewModeUtilities()
|
||||
const { getShortcutDisplayText, registerShortcuts } = useViewerShortcuts()
|
||||
const mp = useMixpanel()
|
||||
|
||||
const isManuallyOpened = ref(false)
|
||||
|
||||
const { start: startCloseTimer, stop: cancelCloseTimer } = useTimeoutFn(
|
||||
() => {
|
||||
open.value = false
|
||||
},
|
||||
3000,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
registerShortcuts({
|
||||
SetViewModeDefault: () => handleViewModeChange(ViewMode.DEFAULT, true),
|
||||
SetViewModeDefaultEdges: () => handleViewModeChange(ViewMode.DEFAULT_EDGES, true),
|
||||
SetViewModeShaded: () => handleViewModeChange(ViewMode.SHADED, true),
|
||||
SetViewModePen: () => handleViewModeChange(ViewMode.PEN, true),
|
||||
SetViewModeArctic: () => handleViewModeChange(ViewMode.ARCTIC, true),
|
||||
SetViewModeColors: () => handleViewModeChange(ViewMode.COLORS, true)
|
||||
})
|
||||
|
||||
const isActiveMode = (mode: ViewMode) => mode === currentViewMode.value
|
||||
|
||||
const viewModeShortcuts = Object.values(ViewModeShortcuts)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'force-close-others'): void
|
||||
}>()
|
||||
|
||||
const handleViewModeChange = (mode: ViewMode, isShortcut = false) => {
|
||||
setViewMode(mode)
|
||||
cancelCloseTimer()
|
||||
|
||||
if (isShortcut) {
|
||||
emit('force-close-others')
|
||||
open.value = true
|
||||
startCloseTimer()
|
||||
}
|
||||
|
||||
mp.track('Viewer Action', {
|
||||
type: 'action',
|
||||
name: 'set-view-mode',
|
||||
mode
|
||||
})
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelCloseTimer()
|
||||
})
|
||||
</script>
|
||||
@@ -1,52 +1,48 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<template>
|
||||
<div ref="menuWrapper" class="relative z-30">
|
||||
<ViewerControlsButtonToggle flat secondary :active="open" @click="open = !open">
|
||||
<ViewerMenu v-model:open="open" tooltip="Views">
|
||||
<template #trigger-icon>
|
||||
<IconViews class="w-5 h-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
<Transition
|
||||
enter-active-class="transform ease-out duration-300 transition"
|
||||
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
</template>
|
||||
<div
|
||||
class="w-32 sm:w-40 max-h-64 simple-scrollbar overflow-y-auto flex flex-col p-1.5"
|
||||
@mouseenter="cancelCloseTimer"
|
||||
@mouseleave="isManuallyOpened ? undefined : startCloseTimer"
|
||||
@focusin="cancelCloseTimer"
|
||||
@focusout="isManuallyOpened ? undefined : startCloseTimer"
|
||||
>
|
||||
<div
|
||||
v-if="open"
|
||||
class="absolute translate-x-0 w-32 left-10 sm:left-12 -top-0 sm:-top-2 bg-foundation max-h-64 simple-scrollbar overflow-y-auto outline outline-2 outline-primary-muted rounded-lg shadow-lg overflow-hidden flex flex-col"
|
||||
>
|
||||
<!-- Canonical views first -->
|
||||
<div v-for="view in canonicalViews" :key="view.name">
|
||||
<button
|
||||
class="hover:bg-primary-muted text-foreground w-full h-full text-body-xs py-1"
|
||||
@click="setView(view.name.toLowerCase() as CanonicalView)"
|
||||
>
|
||||
{{ view.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="views.length !== 0" class="w-full border-b"></div>
|
||||
<!-- Any model other views -->
|
||||
<div v-for="view in views" :key="view.id">
|
||||
<button
|
||||
class="hover:bg-primary-muted text-foreground w-full h-full text-body-xs py-1 transition"
|
||||
:title="view.name"
|
||||
@click="setView(view)"
|
||||
>
|
||||
<span class="block truncate max-w-28 mx-auto">
|
||||
{{ view.name ? view.name : view.id }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-for="shortcut in viewShortcuts" :key="shortcut.name">
|
||||
<ViewerMenuItem
|
||||
:label="shortcut.name"
|
||||
hide-active-tick
|
||||
:active="activeView === shortcut.name.toLowerCase()"
|
||||
:shortcut="getShortcutDisplayText(shortcut, { hideName: true })"
|
||||
@click="handleViewChange(shortcut.name.toLowerCase() as CanonicalView)"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div v-if="views.length !== 0" class="w-full border-b my-1"></div>
|
||||
|
||||
<ViewerMenuItem
|
||||
v-for="view in views"
|
||||
:key="view.id"
|
||||
hide-active-tick
|
||||
:active="activeView === view.id"
|
||||
:label="view.name ? view.name : view.id"
|
||||
@click="handleViewChange(view)"
|
||||
/>
|
||||
</div>
|
||||
</ViewerMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CanonicalView, SpeckleView } from '~~/../viewer/dist'
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import type { CanonicalView, SpeckleView } from '@speckle/viewer'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
|
||||
import { useCameraUtilities } from '~~/lib/viewer/composables/ui'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { useCameraUtilities, useViewerShortcuts } from '~~/lib/viewer/composables/ui'
|
||||
import { ViewShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts'
|
||||
import { useViewerCameraControlEndTracker } from '~~/lib/viewer/composables/viewer'
|
||||
|
||||
const {
|
||||
viewer: {
|
||||
@@ -54,14 +50,37 @@ const {
|
||||
}
|
||||
} = useInjectedViewerState()
|
||||
const { setView: setViewRaw } = useCameraUtilities()
|
||||
const { getShortcutDisplayText, registerShortcuts } = useViewerShortcuts()
|
||||
const mp = useMixpanel()
|
||||
|
||||
const open = ref(false)
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
const isManuallyOpened = ref(false)
|
||||
const activeView = ref<string | null>(null)
|
||||
|
||||
const menuWrapper = ref(null)
|
||||
// Clear active view when camera control ends
|
||||
useViewerCameraControlEndTracker(() => {
|
||||
activeView.value = null
|
||||
})
|
||||
|
||||
const setView = (v: CanonicalView | SpeckleView) => {
|
||||
const { start: startCloseTimer, stop: cancelCloseTimer } = useTimeoutFn(
|
||||
() => {
|
||||
open.value = false
|
||||
},
|
||||
3000,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const handleViewChange = (v: CanonicalView | SpeckleView, isShortcut = false) => {
|
||||
setViewRaw(v)
|
||||
cancelCloseTimer()
|
||||
activeView.value = typeof v === 'string' ? v : v.id
|
||||
|
||||
if (isShortcut) {
|
||||
emit('force-close-others')
|
||||
open.value = true
|
||||
startCloseTimer()
|
||||
}
|
||||
|
||||
mp.track('Viewer Action', {
|
||||
type: 'action',
|
||||
name: 'set-view',
|
||||
@@ -69,15 +88,21 @@ const setView = (v: CanonicalView | SpeckleView) => {
|
||||
})
|
||||
}
|
||||
|
||||
const canonicalViews = [
|
||||
{ name: 'Top' },
|
||||
{ name: 'Front' },
|
||||
{ name: 'Left' },
|
||||
{ name: 'Back' },
|
||||
{ name: 'Right' }
|
||||
]
|
||||
registerShortcuts({
|
||||
SetViewTop: () => handleViewChange('top', true),
|
||||
SetViewFront: () => handleViewChange('front', true),
|
||||
SetViewLeft: () => handleViewChange('left', true),
|
||||
SetViewBack: () => handleViewChange('back', true),
|
||||
SetViewRight: () => handleViewChange('right', true)
|
||||
})
|
||||
|
||||
onClickOutside(menuWrapper, () => {
|
||||
open.value = false
|
||||
const viewShortcuts = Object.values(ViewShortcuts)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'force-close-others'): void
|
||||
}>()
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelCloseTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -94,19 +94,19 @@ export const useAutomationRunDetailsFns = () => {
|
||||
switch (status) {
|
||||
case AutomateRunStatus.Pending:
|
||||
case AutomateRunStatus.Initializing:
|
||||
classParts.push('bg-warning-lighter text-warning-darker')
|
||||
classParts.push('bg-warning-lighter')
|
||||
break
|
||||
case AutomateRunStatus.Running:
|
||||
classParts.push('bg-info-lighter text-info-darker')
|
||||
classParts.push('bg-info-lighter')
|
||||
break
|
||||
case AutomateRunStatus.Failed:
|
||||
case AutomateRunStatus.Exception:
|
||||
case AutomateRunStatus.Canceled:
|
||||
case AutomateRunStatus.Timeout:
|
||||
classParts.push('bg-danger-lighter text-danger-darker')
|
||||
classParts.push('bg-danger-lighter')
|
||||
break
|
||||
case AutomateRunStatus.Succeeded:
|
||||
classParts.push('bg-success-lighter text-success-darker')
|
||||
classParts.push('bg-success-lighter')
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -199,12 +199,24 @@ export const useBillingActions = () => {
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Your payment was canceled'
|
||||
})
|
||||
|
||||
mixpanel.track('Workspace Upgrade Cancelled', {
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: workspace.id
|
||||
})
|
||||
} else {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Your workspace plan was successfully updated'
|
||||
})
|
||||
|
||||
mixpanel.track('Workspace Upgraded', {
|
||||
plan: workspace.plan?.name,
|
||||
cycle: workspace.subscription?.billingInterval,
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: workspace.id
|
||||
})
|
||||
|
||||
if (import.meta.server) {
|
||||
await sendWebhook(defaultZapierWebhookUrl, {
|
||||
workspaceId: workspace.id,
|
||||
|
||||
@@ -1,60 +1,93 @@
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import type { ToastNotification } from '@speckle/ui-components'
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { ToastNotificationType } from '@speckle/ui-components'
|
||||
import { useSynchronizedCookie } from '~/lib/common/composables/reactiveCookie'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
/**
|
||||
* Persisting toast state between reqs and between CSR & SSR loads so that we can trigger
|
||||
* toasts anywhere and anytime
|
||||
*/
|
||||
const useGlobalToastState = () =>
|
||||
useSynchronizedCookie<Optional<ToastNotification>>('global-toast-state')
|
||||
useSynchronizedCookie<Optional<ToastNotification[]>>('global-toast-state')
|
||||
|
||||
/**
|
||||
* Set up a new global toast manager/renderer (don't use this in multiple components that live at the same time)
|
||||
*/
|
||||
export function useGlobalToastManager() {
|
||||
const stateNotification = useGlobalToastState()
|
||||
|
||||
const currentNotification = ref(stateNotification.value)
|
||||
const readOnlyNotification = computed(() => currentNotification.value)
|
||||
|
||||
const dismiss = () => {
|
||||
currentNotification.value = undefined
|
||||
stateNotification.value = undefined
|
||||
type Timeout = {
|
||||
id: string
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
const { start, stop } = useTimeoutFn(() => {
|
||||
dismiss()
|
||||
}, 4000)
|
||||
const stateNotification = useGlobalToastState()
|
||||
|
||||
const timeouts = ref<Timeout[]>([])
|
||||
const currentNotifications = ref<ToastNotification[]>(
|
||||
Array.isArray(stateNotification.value) ? stateNotification.value : []
|
||||
)
|
||||
const readOnlyNotification = computed(() => currentNotifications.value)
|
||||
|
||||
// Remove a specific notification from the state
|
||||
const removeNotification = (id: string) => {
|
||||
const index = currentNotifications.value.findIndex((n) => n.id === id)
|
||||
if (index !== -1) {
|
||||
currentNotifications.value.splice(index, 1)
|
||||
// Clean up timeout
|
||||
timeouts.value = timeouts.value.filter((t) => t.id !== id)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a timeout for a notification
|
||||
const createTimeout = (notification: ToastNotification) => {
|
||||
const { stop } = useTimeoutFn(() => {
|
||||
if (notification.id) {
|
||||
removeNotification(notification.id)
|
||||
}
|
||||
}, 4000)
|
||||
return stop
|
||||
}
|
||||
|
||||
watch(
|
||||
stateNotification,
|
||||
(newVal) => {
|
||||
if (!newVal) return
|
||||
if (import.meta.server) {
|
||||
currentNotification.value = newVal
|
||||
return
|
||||
currentNotifications.value = newVal
|
||||
|
||||
// Create timeout for the new notification
|
||||
const index = currentNotifications.value.length - 1
|
||||
const lastNotification = newVal[index]
|
||||
|
||||
if (lastNotification && !lastNotification.autoClose) {
|
||||
timeouts.value.push({
|
||||
id: lastNotification.id as string,
|
||||
stop: createTimeout(lastNotification)
|
||||
})
|
||||
}
|
||||
|
||||
// First dismiss old notification, then set a new one on next tick
|
||||
// this is so that the old one actually disappears from the screen for the user,
|
||||
// instead of just having its contents replaced
|
||||
dismiss()
|
||||
|
||||
nextTick(() => {
|
||||
currentNotification.value = newVal
|
||||
|
||||
// (re-)init timeout
|
||||
stop()
|
||||
if (newVal.autoClose !== false) start()
|
||||
})
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
return { currentNotification: readOnlyNotification, dismiss }
|
||||
// Function to dismiss a specific notification
|
||||
const dismiss = (notification: ToastNotification) => {
|
||||
if (!notification.id) return
|
||||
|
||||
const targetTimeout = timeouts.value.find((t) => t.id === notification.id)
|
||||
if (targetTimeout) {
|
||||
targetTimeout.stop()
|
||||
}
|
||||
removeNotification(notification.id as string)
|
||||
}
|
||||
|
||||
// Dismiss all notifications
|
||||
const dismissAll = () => {
|
||||
timeouts.value.forEach((timeout) => timeout.stop())
|
||||
timeouts.value = []
|
||||
currentNotifications.value = []
|
||||
}
|
||||
|
||||
return { currentNotifications: readOnlyNotification, dismiss, dismissAll }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,7 +101,11 @@ export function useGlobalToast() {
|
||||
* Trigger a new toast notification
|
||||
*/
|
||||
const triggerNotification = (notification: ToastNotification) => {
|
||||
stateNotification.value = notification
|
||||
const newNotification = { ...notification, id: nanoid() }
|
||||
|
||||
stateNotification.value
|
||||
? stateNotification.value.push(newNotification)
|
||||
: (stateNotification.value = [newNotification])
|
||||
|
||||
if (import.meta.server) {
|
||||
logger.info('Queued SSR toast notification', notification)
|
||||
|
||||
@@ -83,7 +83,7 @@ const documents = {
|
||||
"\n query ProjectPageSettingsCollaboratorsWorkspace($workspaceId: String!) {\n workspace(id: $workspaceId) {\n ...ProjectPageTeamInternals_Workspace\n }\n }\n": types.ProjectPageSettingsCollaboratorsWorkspaceDocument,
|
||||
"\n query ProjectPageSettingsGeneral($projectId: String!) {\n project(id: $projectId) {\n id\n role\n ...ProjectPageSettingsGeneralBlockProjectInfo_Project\n ...ProjectPageSettingsGeneralBlockAccess_Project\n ...ProjectPageSettingsGeneralBlockDiscussions_Project\n ...ProjectPageSettingsGeneralBlockLeave_Project\n ...ProjectPageSettingsGeneralBlockDelete_Project\n ...ProjectPageTeamInternals_Project\n }\n }\n": types.ProjectPageSettingsGeneralDocument,
|
||||
"\n fragment ProjectPageSettingsGeneralBlockAccess_Project on Project {\n id\n visibility\n }\n": types.ProjectPageSettingsGeneralBlockAccess_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageSettingsGeneralBlockDelete_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n slug\n }\n automations(limit: 0) {\n totalCount\n }\n }\n": types.ProjectPageSettingsGeneralBlockDelete_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageSettingsGeneralBlockDelete_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n slug\n }\n }\n": types.ProjectPageSettingsGeneralBlockDelete_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageSettingsGeneralBlockDiscussions_Project on Project {\n id\n visibility\n allowPublicComments\n }\n": types.ProjectPageSettingsGeneralBlockDiscussions_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageSettingsGeneralBlockLeave_Project on Project {\n id\n name\n role\n team {\n role\n user {\n ...LimitedUserAvatar\n role\n }\n }\n workspace {\n id\n }\n }\n": types.ProjectPageSettingsGeneralBlockLeave_ProjectFragmentDoc,
|
||||
"\n fragment ProjectPageSettingsGeneralBlockProjectInfo_Project on Project {\n id\n role\n name\n description\n }\n": types.ProjectPageSettingsGeneralBlockProjectInfo_ProjectFragmentDoc,
|
||||
@@ -674,7 +674,7 @@ export function graphql(source: "\n fragment ProjectPageSettingsGeneralBlockAcc
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ProjectPageSettingsGeneralBlockDelete_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n slug\n }\n automations(limit: 0) {\n totalCount\n }\n }\n"): (typeof documents)["\n fragment ProjectPageSettingsGeneralBlockDelete_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n slug\n }\n automations(limit: 0) {\n totalCount\n }\n }\n"];
|
||||
export function graphql(source: "\n fragment ProjectPageSettingsGeneralBlockDelete_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n slug\n }\n }\n"): (typeof documents)["\n fragment ProjectPageSettingsGeneralBlockDelete_Project on Project {\n id\n name\n role\n models(limit: 0) {\n totalCount\n }\n commentThreads(limit: 0) {\n totalCount\n }\n workspace {\n slug\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
import type { InviteServerItem } from '~~/lib/invites/helpers/types'
|
||||
import { Roles } from '@speckle/shared'
|
||||
|
||||
export const emptyInviteServerItem: InviteServerItem = {
|
||||
email: '',
|
||||
serverRole: Roles.Server.User,
|
||||
project: undefined
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ServerRoles } from '@speckle/shared'
|
||||
import type { FormSelectProjects_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
export type InviteServerItem = {
|
||||
email: string
|
||||
serverRole: ServerRoles
|
||||
project?: FormSelectProjects_ProjectFragment
|
||||
}
|
||||
|
||||
export interface InviteServerForm {
|
||||
fields: InviteServerItem[]
|
||||
}
|
||||
@@ -308,13 +308,19 @@ export function useInviteUserToProject() {
|
||||
if (err) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Invitation failed',
|
||||
title:
|
||||
input.length > 1
|
||||
? "Couldn't send invites"
|
||||
: `Coudldn't send invite to ${input[0].email}`,
|
||||
description: err
|
||||
})
|
||||
} else {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Invite successfully sent'
|
||||
title:
|
||||
input.length > 1
|
||||
? 'Invites successfully send'
|
||||
: `Invite successfully sent to ${input[0].email}`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -55,13 +55,19 @@ export function useInviteUserToServer() {
|
||||
if (res?.data?.serverInviteBatchCreate) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: `Server invite${finalInput.length > 1 ? 's' : ''} sent`
|
||||
title:
|
||||
finalInput.length > 1
|
||||
? 'Server invites sent'
|
||||
: `Server invite sent to ${finalInput[0].email}`
|
||||
})
|
||||
} else {
|
||||
const errMsg = getFirstErrorMessage(res?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: `Couldn't send invite${finalInput.length > 1 ? 's' : ''}`,
|
||||
title:
|
||||
finalInput.length > 1
|
||||
? "Couldn't send invites"
|
||||
: `Couldn't send invite to ${finalInput[0].email}`,
|
||||
description: errMsg
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useFilterUtilities,
|
||||
useSelectionUtilities
|
||||
} from '~~/lib/viewer/composables/ui'
|
||||
import { CameraController } from '@speckle/viewer'
|
||||
import { CameraController, ViewMode } from '@speckle/viewer'
|
||||
import type { NumericPropertyInfo } from '@speckle/viewer'
|
||||
|
||||
type SerializedViewerState = SpeckleViewer.ViewerState.SerializedViewerState
|
||||
@@ -105,6 +105,7 @@ export function useStateSerialization() {
|
||||
isOrthoProjection: state.ui.camera.isOrthoProjection.value,
|
||||
zoom: (get(camControls, '_zoom') as number) || 1 // kinda hacky, _zoom is a protected prop
|
||||
},
|
||||
viewMode: state.ui.viewMode.value,
|
||||
sectionBox: state.ui.sectionBox.value
|
||||
? {
|
||||
min: box.min.toArray(),
|
||||
@@ -141,7 +142,8 @@ export function useApplySerializedState() {
|
||||
highlightedObjectIds,
|
||||
explodeFactor,
|
||||
lightConfig,
|
||||
diff
|
||||
diff,
|
||||
viewMode
|
||||
},
|
||||
resources: {
|
||||
request: { resourceIdString }
|
||||
@@ -282,6 +284,13 @@ export function useApplySerializedState() {
|
||||
await endDiff()
|
||||
}
|
||||
|
||||
// Restore view mode
|
||||
if (state.ui.viewMode) {
|
||||
viewMode.value = state.ui.viewMode
|
||||
} else {
|
||||
viewMode.value = ViewMode.DEFAULT
|
||||
}
|
||||
|
||||
explodeFactor.value = state.ui.explodeFactor
|
||||
lightConfig.value = {
|
||||
...lightConfig.value,
|
||||
|
||||
@@ -6,16 +6,17 @@ import {
|
||||
MeasurementType,
|
||||
FilteringExtension
|
||||
} from '@speckle/viewer'
|
||||
import type {
|
||||
FilteringState,
|
||||
PropertyInfo,
|
||||
SunLightConfiguration,
|
||||
SpeckleView,
|
||||
MeasurementOptions,
|
||||
DiffResult,
|
||||
Viewer,
|
||||
WorldTree,
|
||||
VisualDiffMode
|
||||
import {
|
||||
type FilteringState,
|
||||
type PropertyInfo,
|
||||
type SunLightConfiguration,
|
||||
type SpeckleView,
|
||||
type MeasurementOptions,
|
||||
type DiffResult,
|
||||
type Viewer,
|
||||
type WorldTree,
|
||||
type VisualDiffMode,
|
||||
ViewMode
|
||||
} from '@speckle/viewer'
|
||||
import type { MaybeRef } from '@vueuse/shared'
|
||||
import { inject, ref, provide } from 'vue'
|
||||
@@ -66,7 +67,6 @@ import {
|
||||
import { useSynchronizedCookie } from '~~/lib/common/composables/reactiveCookie'
|
||||
import { buildManualPromise } from '@speckle/ui-components'
|
||||
import { PassReader } from '../extensions/PassReader'
|
||||
import { ViewModesKeys } from '../extensions/ViewModesKeys'
|
||||
|
||||
export type LoadedModel = NonNullable<
|
||||
Get<ViewerLoadedResourcesQuery, 'project.models.items[0]'>
|
||||
@@ -259,6 +259,7 @@ export type InjectableViewerState = Readonly<{
|
||||
target: Ref<Vector3>
|
||||
isOrthoProjection: Ref<boolean>
|
||||
}
|
||||
viewMode: Ref<ViewMode>
|
||||
diff: {
|
||||
newVersion: ComputedRef<ViewerModelVersionCardItemFragment | undefined>
|
||||
oldVersion: ComputedRef<ViewerModelVersionCardItemFragment | undefined>
|
||||
@@ -338,7 +339,6 @@ function createViewerDataBuilder(params: { viewerDebug: boolean }) {
|
||||
verbose: !!(import.meta.client && params.viewerDebug)
|
||||
})
|
||||
viewer.createExtension(PassReader)
|
||||
viewer.createExtension(ViewModesKeys)
|
||||
const initPromise = viewer.init()
|
||||
|
||||
return {
|
||||
@@ -931,6 +931,7 @@ function setupInterfaceState(
|
||||
if (explodeFactor.value !== 0) return true
|
||||
return false
|
||||
})
|
||||
const viewMode = ref<ViewMode>(ViewMode.DEFAULT)
|
||||
|
||||
const highlightedObjectIds = ref([] as string[])
|
||||
const spotlightUserSessionId = ref(null as Nullable<string>)
|
||||
@@ -993,6 +994,7 @@ function setupInterfaceState(
|
||||
target,
|
||||
isOrthoProjection
|
||||
},
|
||||
viewMode,
|
||||
sectionBox: ref(null as Nullable<Box3>),
|
||||
sectionBoxContext: {
|
||||
visible: ref(false),
|
||||
@@ -1078,7 +1080,7 @@ export function useInjectedViewerInterfaceState(): InjectableViewerState['ui'] {
|
||||
|
||||
export function useResetUiState() {
|
||||
const {
|
||||
ui: { camera, sectionBox, highlightedObjectIds, lightConfig }
|
||||
ui: { camera, sectionBox, highlightedObjectIds, lightConfig, viewMode }
|
||||
} = useInjectedViewerState()
|
||||
const { resetFilters } = useFilterUtilities()
|
||||
const { endDiff } = useDiffUtilities()
|
||||
@@ -1088,6 +1090,7 @@ export function useResetUiState() {
|
||||
sectionBox.value = null
|
||||
highlightedObjectIds.value = []
|
||||
lightConfig.value = { ...DefaultLightConfiguration }
|
||||
viewMode.value = ViewMode.DEFAULT
|
||||
resetFilters()
|
||||
endDiff()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { difference, flatten, isEqual, uniq } from 'lodash-es'
|
||||
import {
|
||||
ViewMode,
|
||||
type PropertyInfo,
|
||||
type StringPropertyInfo,
|
||||
type SunLightConfiguration
|
||||
} from '@speckle/viewer'
|
||||
import {
|
||||
ViewerEvent,
|
||||
VisualDiffMode,
|
||||
@@ -6,12 +12,9 @@ import {
|
||||
UpdateFlags,
|
||||
SectionOutlines,
|
||||
SectionToolEvent,
|
||||
SectionTool
|
||||
} from '@speckle/viewer'
|
||||
import type {
|
||||
PropertyInfo,
|
||||
StringPropertyInfo,
|
||||
SunLightConfiguration
|
||||
SectionTool,
|
||||
ViewModes,
|
||||
ViewModeEvent
|
||||
} from '@speckle/viewer'
|
||||
import { useAuthCookie } from '~~/lib/auth/composables/auth'
|
||||
import type {
|
||||
@@ -636,6 +639,44 @@ function useViewerFiltersIntegration() {
|
||||
)
|
||||
}
|
||||
|
||||
function useViewerViewModeIntegration() {
|
||||
const {
|
||||
ui: { viewMode },
|
||||
viewer: { instance }
|
||||
} = useInjectedViewerState()
|
||||
|
||||
const viewModes = instance.getExtension(ViewModes)
|
||||
const onViewModeChanged = (mode: ViewMode) => {
|
||||
viewMode.value = mode
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!viewMode.value) {
|
||||
viewMode.value = ViewMode.DEFAULT
|
||||
}
|
||||
viewModes.on(ViewModeEvent.Changed, onViewModeChanged)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// Reset view mode to default
|
||||
viewModes.setViewMode(ViewMode.DEFAULT)
|
||||
viewMode.value = ViewMode.DEFAULT
|
||||
|
||||
// Clean up event listener
|
||||
viewModes.removeListener(ViewModeEvent.Changed, onViewModeChanged)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => viewMode.value,
|
||||
(newMode) => {
|
||||
if (viewModes && newMode) {
|
||||
viewModes.setViewMode(newMode)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
|
||||
function useLightConfigIntegration() {
|
||||
const {
|
||||
ui: { lightConfig },
|
||||
@@ -874,6 +915,7 @@ export function useViewerPostSetup() {
|
||||
useViewerSectionBoxIntegration()
|
||||
useViewerCameraIntegration()
|
||||
useViewerFiltersIntegration()
|
||||
useViewerViewModeIntegration()
|
||||
useLightConfigIntegration()
|
||||
useExplodeFactorIntegration()
|
||||
useDiffingIntegration()
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { SpeckleViewer, timeoutAt } from '@speckle/shared'
|
||||
import type { TreeNode, MeasurementOptions, PropertyInfo } from '@speckle/viewer'
|
||||
import { MeasurementsExtension } from '@speckle/viewer'
|
||||
import type {
|
||||
TreeNode,
|
||||
MeasurementOptions,
|
||||
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 } from '~/lib/viewer/composables/setup/embed'
|
||||
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 {
|
||||
@@ -15,6 +20,13 @@ import {
|
||||
import { useDiffBuilderUtilities } from '~~/lib/viewer/composables/setup/diff'
|
||||
import { useTourStageState } from '~~/lib/viewer/composables/tour'
|
||||
import { Vector3, Box3 } from 'three'
|
||||
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'
|
||||
|
||||
export function useSectionBoxUtilities() {
|
||||
const { instance } = useInjectedViewer()
|
||||
@@ -475,3 +487,93 @@ export function useHighlightedObjectsUtilities() {
|
||||
clearHighlightedObjects
|
||||
}
|
||||
}
|
||||
|
||||
export function useViewModeUtilities() {
|
||||
const { instance } = useInjectedViewer()
|
||||
const { viewMode } = useInjectedViewerInterfaceState()
|
||||
|
||||
const currentViewMode = computed(() => viewMode.value)
|
||||
|
||||
const setViewMode = (mode: ViewMode) => {
|
||||
const viewModes = instance.getExtension(ViewModes)
|
||||
if (viewModes) {
|
||||
viewModes.setViewMode(mode)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentViewMode,
|
||||
setViewMode
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import {
|
||||
Extension,
|
||||
InputEvent,
|
||||
ViewMode,
|
||||
ViewModes,
|
||||
type IViewer
|
||||
} from '@speckle/viewer'
|
||||
|
||||
export class ViewModesKeys extends Extension {
|
||||
public get inject() {
|
||||
return [ViewModes]
|
||||
}
|
||||
|
||||
constructor(viewer: IViewer, protected viewModes: ViewModes) {
|
||||
super(viewer)
|
||||
const renderer = viewer.getRenderer()
|
||||
|
||||
renderer.input.on(InputEvent.KeyUp, (arg: KeyboardEvent) => {
|
||||
// Dont trigger on inputs, textareas or contenteditable elements
|
||||
// We should handle this more gracefully but it works for now
|
||||
if (
|
||||
arg.target &&
|
||||
((arg.target as HTMLElement).tagName.toLowerCase() === 'input' ||
|
||||
(arg.target as HTMLElement).tagName.toLowerCase() === 'textarea' ||
|
||||
(arg.target as HTMLElement).getAttribute('contenteditable') === 'true')
|
||||
)
|
||||
return
|
||||
|
||||
switch (arg.key) {
|
||||
case '1':
|
||||
viewModes.setViewMode(ViewMode.DEFAULT)
|
||||
break
|
||||
case '2':
|
||||
viewModes.setViewMode(ViewMode.DEFAULT_EDGES)
|
||||
break
|
||||
case '3':
|
||||
viewModes.setViewMode(ViewMode.SHADED)
|
||||
break
|
||||
case '4':
|
||||
viewModes.setViewMode(ViewMode.PEN)
|
||||
break
|
||||
case '5':
|
||||
viewModes.setViewMode(ViewMode.ARCTIC)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { ModifierKeys } from '@speckle/ui-components'
|
||||
import { ViewMode } from '@speckle/viewer'
|
||||
|
||||
export const PanelShortcuts = {
|
||||
ToggleModels: {
|
||||
name: 'Models',
|
||||
description: 'Toggle models panel',
|
||||
modifiers: [ModifierKeys.Shift],
|
||||
key: 'M',
|
||||
action: 'ToggleModels'
|
||||
},
|
||||
ToggleExplorer: {
|
||||
name: 'Scene explorer',
|
||||
description: 'Toggle scene explorer panel',
|
||||
modifiers: [ModifierKeys.Shift],
|
||||
key: 'E',
|
||||
action: 'ToggleExplorer'
|
||||
},
|
||||
ToggleDiscussions: {
|
||||
name: 'Discussions',
|
||||
description: 'Toggle discussions panel',
|
||||
modifiers: [ModifierKeys.Shift],
|
||||
key: 'D',
|
||||
action: 'ToggleDiscussions'
|
||||
}
|
||||
} as const
|
||||
|
||||
export const ToolShortcuts = {
|
||||
ToggleMeasurements: {
|
||||
name: 'Measure',
|
||||
description: 'Toggle measurement mode',
|
||||
modifiers: [ModifierKeys.Shift],
|
||||
key: 'R',
|
||||
action: 'ToggleMeasurements'
|
||||
},
|
||||
ToggleProjection: {
|
||||
name: 'Projection',
|
||||
description: 'Toggle between orthographic and perspective projection',
|
||||
modifiers: [ModifierKeys.Shift],
|
||||
key: 'P',
|
||||
action: 'ToggleProjection'
|
||||
},
|
||||
ToggleSectionBox: {
|
||||
name: 'Section',
|
||||
description: 'Toggle section box',
|
||||
modifiers: [ModifierKeys.Shift],
|
||||
key: 'B',
|
||||
action: 'ToggleSectionBox'
|
||||
},
|
||||
ZoomExtentsOrSelection: {
|
||||
name: 'Fit',
|
||||
description: 'Zoom to fit selection or entire model',
|
||||
modifiers: [ModifierKeys.Shift],
|
||||
key: 'space',
|
||||
action: 'ZoomExtentsOrSelection'
|
||||
}
|
||||
} as const
|
||||
|
||||
export const ViewModeShortcuts = {
|
||||
SetViewModeDefault: {
|
||||
name: 'Rendered',
|
||||
description: 'Set view mode to Rendered',
|
||||
modifiers: [ModifierKeys.Shift],
|
||||
key: 'Digit1',
|
||||
action: 'SetViewModeDefault',
|
||||
viewMode: ViewMode.DEFAULT
|
||||
},
|
||||
SetViewModeDefaultEdges: {
|
||||
name: 'Rendered + Edges',
|
||||
description: 'Set view mode to Rendered + Edges',
|
||||
modifiers: [ModifierKeys.Shift],
|
||||
key: 'Digit2',
|
||||
action: 'SetViewModeDefaultEdges',
|
||||
viewMode: ViewMode.DEFAULT_EDGES
|
||||
},
|
||||
SetViewModeShaded: {
|
||||
name: 'Solid',
|
||||
description: 'Set view mode to Solid',
|
||||
modifiers: [ModifierKeys.Shift],
|
||||
key: 'Digit3',
|
||||
action: 'SetViewModeShaded',
|
||||
viewMode: ViewMode.SHADED
|
||||
},
|
||||
SetViewModePen: {
|
||||
name: 'Pen',
|
||||
description: 'Set view mode to Pen',
|
||||
modifiers: [ModifierKeys.Shift],
|
||||
key: 'Digit4',
|
||||
action: 'SetViewModePen',
|
||||
viewMode: ViewMode.PEN
|
||||
},
|
||||
SetViewModeArctic: {
|
||||
name: 'Arctic',
|
||||
description: 'Set view mode to Arctic',
|
||||
modifiers: [ModifierKeys.Shift],
|
||||
key: 'Digit5',
|
||||
action: 'SetViewModeArctic',
|
||||
viewMode: ViewMode.ARCTIC
|
||||
},
|
||||
SetViewModeColors: {
|
||||
name: 'Shaded',
|
||||
description: 'Set view mode to Shaded',
|
||||
modifiers: [ModifierKeys.Shift],
|
||||
key: 'Digit6',
|
||||
action: 'SetViewModeColors',
|
||||
viewMode: ViewMode.COLORS
|
||||
}
|
||||
} as const
|
||||
|
||||
export const ViewShortcuts = {
|
||||
SetViewTop: {
|
||||
name: 'Top',
|
||||
description: 'Set view to Top',
|
||||
modifiers: [ModifierKeys.AltOrOpt],
|
||||
key: 'Digit1',
|
||||
action: 'SetViewTop'
|
||||
},
|
||||
SetViewFront: {
|
||||
name: 'Front',
|
||||
description: 'Set view to Front',
|
||||
modifiers: [ModifierKeys.AltOrOpt],
|
||||
key: 'Digit2',
|
||||
action: 'SetViewFront'
|
||||
},
|
||||
SetViewLeft: {
|
||||
name: 'Left',
|
||||
description: 'Set view to Left',
|
||||
modifiers: [ModifierKeys.AltOrOpt],
|
||||
key: 'Digit3',
|
||||
action: 'SetViewLeft'
|
||||
},
|
||||
SetViewBack: {
|
||||
name: 'Back',
|
||||
description: 'Set view to Back',
|
||||
modifiers: [ModifierKeys.AltOrOpt],
|
||||
key: 'Digit4',
|
||||
action: 'SetViewBack'
|
||||
},
|
||||
SetViewRight: {
|
||||
name: 'Right',
|
||||
description: 'Set view to Right',
|
||||
modifiers: [ModifierKeys.AltOrOpt],
|
||||
key: 'Digit5',
|
||||
action: 'SetViewRight'
|
||||
}
|
||||
} as const
|
||||
|
||||
export const ViewerShortcuts = {
|
||||
...ViewModeShortcuts,
|
||||
...PanelShortcuts,
|
||||
...ToolShortcuts,
|
||||
...ViewShortcuts
|
||||
} as const
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ModifierKeys } from '@speckle/ui-components'
|
||||
import type { ViewMode } from '@speckle/viewer'
|
||||
import type { ViewerShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts'
|
||||
|
||||
export type BaseShortcut = {
|
||||
name: string
|
||||
description: string
|
||||
modifiers: readonly ModifierKeys[]
|
||||
key: string
|
||||
action: string
|
||||
}
|
||||
|
||||
export type ViewModeShortcut = BaseShortcut & {
|
||||
viewMode: ViewMode
|
||||
}
|
||||
|
||||
export type ViewerShortcut = (typeof ViewerShortcuts)[keyof typeof ViewerShortcuts]
|
||||
export type ViewerShortcutAction = keyof typeof ViewerShortcuts
|
||||
@@ -399,12 +399,13 @@ export async function init() {
|
||||
const app = express()
|
||||
app.disable('x-powered-by')
|
||||
|
||||
Logging(app)
|
||||
|
||||
// Moves things along automatically on restart.
|
||||
// Should perhaps be done manually?
|
||||
await migrateDbToLatest({ region: 'main', db: knex })
|
||||
|
||||
// Logging relies on 'regions' table in the database, so much be initialized after migrations
|
||||
await Logging(app)
|
||||
|
||||
app.use(cookieParser())
|
||||
app.use(DetermineRequestIdMiddleware)
|
||||
app.use(determineClientIpAddressMiddleware)
|
||||
|
||||
@@ -14,7 +14,9 @@ type MetricConfig = {
|
||||
prefix?: string
|
||||
labels?: Record<string, string>
|
||||
buckets?: Record<string, number[]>
|
||||
knex: Knex
|
||||
getDbClients: () => Promise<
|
||||
Array<{ client: Knex; isMain: boolean; regionKey: string }>
|
||||
>
|
||||
}
|
||||
|
||||
type HighFrequencyMonitor = {
|
||||
|
||||
@@ -31,16 +31,17 @@ type MetricConfig = {
|
||||
prefix?: string
|
||||
labels?: Record<string, string>
|
||||
buckets?: Record<BucketName, number[]>
|
||||
knex: Knex
|
||||
getDbClients: () => Promise<
|
||||
Array<{ client: Knex; isMain: boolean; regionKey: string }>
|
||||
>
|
||||
}
|
||||
|
||||
export const knexConnections = (registry: Registry, config: MetricConfig): Metric => {
|
||||
const registers = registry ? [registry] : undefined
|
||||
const namePrefix = config.prefix ?? ''
|
||||
const labels = config.labels ?? {}
|
||||
const labelNames = Object.keys(labels)
|
||||
const labelNames = [...Object.keys(labels), 'region']
|
||||
const buckets = { ...DEFAULT_KNEX_TOTAL_BUCKETS, ...config.buckets }
|
||||
const knex = config.knex
|
||||
|
||||
const knexConnectionsFree = new Histogram({
|
||||
name: namePrefix + KNEX_CONNECTIONS_FREE,
|
||||
@@ -91,15 +92,24 @@ export const knexConnections = (registry: Registry, config: MetricConfig): Metri
|
||||
})
|
||||
|
||||
return {
|
||||
collect: () => {
|
||||
const connPool = knex.client.pool
|
||||
collect: async () => {
|
||||
for (const dbClient of await config.getDbClients()) {
|
||||
const labelsAndRegion = { ...labels, region: dbClient.regionKey }
|
||||
const connPool = dbClient.client.client.pool
|
||||
|
||||
knexConnectionsFree.observe(labels, connPool.numFree())
|
||||
knexConnectionsUsed.observe(labels, connPool.numUsed())
|
||||
knexPendingAcquires.observe(labels, connPool.numPendingAcquires())
|
||||
knexPendingCreates.observe(labels, connPool.numPendingCreates())
|
||||
knexPendingValidations.observe(labels, connPool.numPendingValidations())
|
||||
knexRemainingCapacity.observe(labels, numberOfFreeConnections(knex))
|
||||
knexConnectionsFree.observe(labelsAndRegion, connPool.numFree())
|
||||
knexConnectionsUsed.observe(labelsAndRegion, connPool.numUsed())
|
||||
knexPendingAcquires.observe(labelsAndRegion, connPool.numPendingAcquires())
|
||||
knexPendingCreates.observe(labelsAndRegion, connPool.numPendingCreates())
|
||||
knexPendingValidations.observe(
|
||||
labelsAndRegion,
|
||||
connPool.numPendingValidations()
|
||||
)
|
||||
knexRemainingCapacity.observe(
|
||||
labelsAndRegion,
|
||||
numberOfFreeConnections(dbClient.client)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ import promBundle from 'express-prom-bundle'
|
||||
|
||||
import { initKnexPrometheusMetrics } from '@/logging/knexMonitoring'
|
||||
import { initHighFrequencyMonitoring } from '@/logging/highFrequencyMetrics/highfrequencyMonitoring'
|
||||
import knex from '@/db/knex'
|
||||
import { highFrequencyMetricsCollectionPeriodMs } from '@/modules/shared/helpers/envHelper'
|
||||
import { startupLogger as logger } from '@/logging/logging'
|
||||
import type express from 'express'
|
||||
import { getAllRegisteredDbClients } from '@/modules/multiregion/utils/dbSelector'
|
||||
|
||||
let prometheusInitialized = false
|
||||
|
||||
export default function (app: express.Express) {
|
||||
export default async function (app: express.Express) {
|
||||
if (!prometheusInitialized) {
|
||||
prometheusInitialized = true
|
||||
prometheusClient.register.clear()
|
||||
@@ -24,14 +24,14 @@ export default function (app: express.Express) {
|
||||
register: prometheusClient.register,
|
||||
collectionPeriodMilliseconds: highFrequencyMetricsCollectionPeriodMs(),
|
||||
config: {
|
||||
knex
|
||||
getDbClients: getAllRegisteredDbClients
|
||||
}
|
||||
})
|
||||
highfrequencyMonitoring.start()
|
||||
|
||||
initKnexPrometheusMetrics({
|
||||
await initKnexPrometheusMetrics({
|
||||
register: prometheusClient.register,
|
||||
db: knex,
|
||||
getAllDbClients: getAllRegisteredDbClients,
|
||||
logger
|
||||
})
|
||||
const expressMetricsMiddleware = promBundle({
|
||||
|
||||
@@ -5,150 +5,228 @@ import { Logger } from 'pino'
|
||||
import { toNDecimalPlaces } from '@/modules/core/utils/formatting'
|
||||
import { omit } from 'lodash'
|
||||
|
||||
export const initKnexPrometheusMetrics = (params: {
|
||||
db: Knex
|
||||
let metricQueryDuration: prometheusClient.Summary<string>
|
||||
let metricQueryErrors: prometheusClient.Counter<string>
|
||||
let metricConnectionAcquisitionDuration: prometheusClient.Histogram<string>
|
||||
let metricConnectionPoolErrors: prometheusClient.Counter<string>
|
||||
let metricConnectionInUseDuration: prometheusClient.Histogram<string>
|
||||
let metricConnectionPoolReapingDuration: prometheusClient.Histogram<string>
|
||||
const initializedRegions: string[] = []
|
||||
let initializedPollingMetrics = false
|
||||
|
||||
export const initKnexPrometheusMetrics = async (params: {
|
||||
getAllDbClients: () => Promise<
|
||||
Array<{ client: Knex; isMain: boolean; regionKey: string }>
|
||||
>
|
||||
register: Registry
|
||||
logger: Logger
|
||||
}) => {
|
||||
const normalizeSqlMethod = (sqlMethod: string) => {
|
||||
if (!sqlMethod) return 'unknown'
|
||||
switch (sqlMethod.toLocaleLowerCase()) {
|
||||
case 'first':
|
||||
return 'select'
|
||||
default:
|
||||
return sqlMethod.toLocaleLowerCase()
|
||||
}
|
||||
if (!initializedPollingMetrics) {
|
||||
initializedPollingMetrics = true
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_free',
|
||||
labelNames: ['region'],
|
||||
help: 'Number of free DB connections',
|
||||
async collect() {
|
||||
for (const dbClient of await params.getAllDbClients()) {
|
||||
this.set(
|
||||
{ region: dbClient.regionKey },
|
||||
dbClient.client.client.pool.numFree()
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_used',
|
||||
labelNames: ['region'],
|
||||
help: 'Number of used DB connections',
|
||||
async collect() {
|
||||
for (const dbClient of await params.getAllDbClients()) {
|
||||
this.set(
|
||||
{ region: dbClient.regionKey },
|
||||
dbClient.client.client.pool.numUsed()
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_pending',
|
||||
labelNames: ['region'],
|
||||
help: 'Number of pending DB connection aquires',
|
||||
async collect() {
|
||||
for (const dbClient of await params.getAllDbClients()) {
|
||||
this.set(
|
||||
{ region: dbClient.regionKey },
|
||||
dbClient.client.client.pool.numPendingAcquires()
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_pending_creates',
|
||||
labelNames: ['region'],
|
||||
help: 'Number of pending DB connection creates',
|
||||
async collect() {
|
||||
for (const dbClient of await params.getAllDbClients()) {
|
||||
this.set(
|
||||
{ region: dbClient.regionKey },
|
||||
dbClient.client.client.pool.numPendingCreates()
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_pending_validations',
|
||||
labelNames: ['region'],
|
||||
help: 'Number of pending DB connection validations. This is a state between pending acquisition and acquiring a connection.',
|
||||
async collect() {
|
||||
for (const dbClient of await params.getAllDbClients()) {
|
||||
this.set(
|
||||
{ region: dbClient.regionKey },
|
||||
dbClient.client.client.pool.numPendingValidations()
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_remaining_capacity',
|
||||
labelNames: ['region'],
|
||||
help: 'Remaining capacity of the DB connection pool',
|
||||
async collect() {
|
||||
for (const dbClient of await params.getAllDbClients()) {
|
||||
this.set(
|
||||
{ region: dbClient.regionKey },
|
||||
numberOfFreeConnections(dbClient.client)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
metricQueryDuration = new prometheusClient.Summary({
|
||||
registers: [params.register],
|
||||
labelNames: ['sqlMethod', 'sqlNumberBindings', 'region'],
|
||||
name: 'speckle_server_knex_query_duration',
|
||||
help: 'Summary of the DB query durations in seconds'
|
||||
})
|
||||
|
||||
metricQueryErrors = new prometheusClient.Counter({
|
||||
registers: [params.register],
|
||||
labelNames: ['sqlMethod', 'sqlNumberBindings', 'region'],
|
||||
name: 'speckle_server_knex_query_errors',
|
||||
help: 'Number of DB queries with errors'
|
||||
})
|
||||
|
||||
metricConnectionAcquisitionDuration = new prometheusClient.Histogram({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_connection_acquisition_duration',
|
||||
labelNames: ['region'],
|
||||
help: 'Summary of the DB connection acquisition duration, from request to acquire connection from pool until successfully acquired, in seconds'
|
||||
})
|
||||
|
||||
metricConnectionPoolErrors = new prometheusClient.Counter({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_connection_acquisition_errors',
|
||||
labelNames: ['region'],
|
||||
help: 'Number of DB connection pool acquisition errors'
|
||||
})
|
||||
|
||||
metricConnectionInUseDuration = new prometheusClient.Histogram({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_connection_usage_duration',
|
||||
labelNames: ['region'],
|
||||
help: 'Summary of the DB connection duration, from successful acquisition of connection from pool until release back to pool, in seconds'
|
||||
})
|
||||
|
||||
metricConnectionPoolReapingDuration = new prometheusClient.Histogram({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_connection_pool_reaping_duration',
|
||||
labelNames: ['region'],
|
||||
help: 'Summary of the DB connection pool reaping duration, in seconds. Reaping is the process of removing idle connections from the pool.'
|
||||
})
|
||||
}
|
||||
|
||||
// configure hooks on knex
|
||||
for (const dbClient of await params.getAllDbClients()) {
|
||||
if (initializedRegions.includes(dbClient.regionKey)) continue
|
||||
initKnexPrometheusMetricsForRegionEvents({
|
||||
logger: params.logger,
|
||||
region: dbClient.regionKey,
|
||||
db: dbClient.client
|
||||
})
|
||||
initializedRegions.push(dbClient.regionKey)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeSqlMethod = (sqlMethod: string) => {
|
||||
if (!sqlMethod) return 'unknown'
|
||||
switch (sqlMethod.toLocaleLowerCase()) {
|
||||
case 'first':
|
||||
return 'select'
|
||||
default:
|
||||
return sqlMethod.toLocaleLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
interface QueryEvent extends Knex.Sql {
|
||||
__knexUid: string
|
||||
__knexTxId: string
|
||||
__knexQueryUid: string
|
||||
}
|
||||
|
||||
const initKnexPrometheusMetricsForRegionEvents = async (params: {
|
||||
region: string
|
||||
db: Knex
|
||||
logger: Logger
|
||||
}) => {
|
||||
const { region, db } = params
|
||||
const queryStartTime: Record<string, number> = {}
|
||||
const connectionAcquisitionStartTime: Record<string, number> = {}
|
||||
const connectionInUseStartTime: Record<string, number> = {}
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_free',
|
||||
help: 'Number of free DB connections',
|
||||
collect() {
|
||||
this.set(params.db.client.pool.numFree())
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_used',
|
||||
help: 'Number of used DB connections',
|
||||
collect() {
|
||||
this.set(params.db.client.pool.numUsed())
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_pending',
|
||||
help: 'Number of pending DB connection aquires',
|
||||
collect() {
|
||||
this.set(params.db.client.pool.numPendingAcquires())
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_pending_creates',
|
||||
help: 'Number of pending DB connection creates',
|
||||
collect() {
|
||||
this.set(params.db.client.pool.numPendingCreates())
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_pending_validations',
|
||||
help: 'Number of pending DB connection validations. This is a state between pending acquisition and acquiring a connection.',
|
||||
collect() {
|
||||
this.set(params.db.client.pool.numPendingValidations())
|
||||
}
|
||||
})
|
||||
|
||||
new prometheusClient.Gauge({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_remaining_capacity',
|
||||
help: 'Remaining capacity of the DB connection pool',
|
||||
collect() {
|
||||
this.set(numberOfFreeConnections(params.db))
|
||||
}
|
||||
})
|
||||
|
||||
const metricQueryDuration = new prometheusClient.Summary({
|
||||
registers: [params.register],
|
||||
labelNames: ['sqlMethod', 'sqlNumberBindings'],
|
||||
name: 'speckle_server_knex_query_duration',
|
||||
help: 'Summary of the DB query durations in seconds'
|
||||
})
|
||||
|
||||
const metricQueryErrors = new prometheusClient.Counter({
|
||||
registers: [params.register],
|
||||
labelNames: ['sqlMethod', 'sqlNumberBindings'],
|
||||
name: 'speckle_server_knex_query_errors',
|
||||
help: 'Number of DB queries with errors'
|
||||
})
|
||||
|
||||
const metricConnectionAcquisitionDuration = new prometheusClient.Histogram({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_connection_acquisition_duration',
|
||||
help: 'Summary of the DB connection acquisition duration, from request to acquire connection from pool until successfully acquired, in seconds'
|
||||
})
|
||||
|
||||
const metricConnectionPoolErrors = new prometheusClient.Counter({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_connection_acquisition_errors',
|
||||
help: 'Number of DB connection pool acquisition errors'
|
||||
})
|
||||
|
||||
const metricConnectionInUseDuration = new prometheusClient.Histogram({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_connection_usage_duration',
|
||||
help: 'Summary of the DB connection duration, from successful acquisition of connection from pool until release back to pool, in seconds'
|
||||
})
|
||||
|
||||
const metricConnectionPoolReapingDuration = new prometheusClient.Histogram({
|
||||
registers: [params.register],
|
||||
name: 'speckle_server_knex_connection_pool_reaping_duration',
|
||||
help: 'Summary of the DB connection pool reaping duration, in seconds. Reaping is the process of removing idle connections from the pool.'
|
||||
})
|
||||
|
||||
// configure hooks on knex
|
||||
|
||||
params.db.on('query', (data) => {
|
||||
db.on('query', (data: QueryEvent) => {
|
||||
const queryId = data.__knexQueryUid + ''
|
||||
queryStartTime[queryId] = performance.now()
|
||||
})
|
||||
|
||||
params.db.on('query-response', (_data, querySpec) => {
|
||||
const queryId = querySpec.__knexQueryUid + ''
|
||||
db.on('query-response', (_response: unknown, data: QueryEvent) => {
|
||||
const queryId = data.__knexQueryUid + ''
|
||||
const durationMs = performance.now() - queryStartTime[queryId]
|
||||
const durationSec = toNDecimalPlaces(durationMs / 1000, 2)
|
||||
delete queryStartTime[queryId]
|
||||
if (!isNaN(durationSec))
|
||||
metricQueryDuration
|
||||
.labels({
|
||||
sqlMethod: normalizeSqlMethod(querySpec.method),
|
||||
sqlNumberBindings: querySpec.bindings?.length || -1
|
||||
region,
|
||||
sqlMethod: normalizeSqlMethod(data.method),
|
||||
sqlNumberBindings: data.bindings?.length || -1
|
||||
})
|
||||
.observe(durationSec)
|
||||
params.logger.debug(
|
||||
{
|
||||
sql: querySpec.sql,
|
||||
sqlMethod: normalizeSqlMethod(querySpec.method),
|
||||
region,
|
||||
sql: data.sql,
|
||||
sqlMethod: normalizeSqlMethod(data.method),
|
||||
sqlQueryId: queryId,
|
||||
sqlQueryDurationMs: toNDecimalPlaces(durationMs, 0),
|
||||
sqlNumberBindings: querySpec.bindings?.length || -1
|
||||
sqlNumberBindings: data.bindings?.length || -1
|
||||
},
|
||||
"DB query successfully completed, for method '{sqlMethod}', after {sqlQueryDurationMs}ms"
|
||||
)
|
||||
})
|
||||
|
||||
params.db.on('query-error', (err, querySpec) => {
|
||||
const queryId = querySpec.__knexQueryUid + ''
|
||||
db.on('query-error', (err: unknown, data: QueryEvent) => {
|
||||
const queryId = data.__knexQueryUid + ''
|
||||
const durationMs = performance.now() - queryStartTime[queryId]
|
||||
const durationSec = toNDecimalPlaces(durationMs / 1000, 2)
|
||||
delete queryStartTime[queryId]
|
||||
@@ -156,25 +234,27 @@ export const initKnexPrometheusMetrics = (params: {
|
||||
if (!isNaN(durationSec))
|
||||
metricQueryDuration
|
||||
.labels({
|
||||
sqlMethod: normalizeSqlMethod(querySpec.method),
|
||||
sqlNumberBindings: querySpec.bindings?.length || -1
|
||||
region,
|
||||
sqlMethod: normalizeSqlMethod(data.method),
|
||||
sqlNumberBindings: data.bindings?.length || -1
|
||||
})
|
||||
.observe(durationSec)
|
||||
metricQueryErrors.inc()
|
||||
params.logger.warn(
|
||||
{
|
||||
err: omit(err, 'detail'),
|
||||
sql: querySpec.sql,
|
||||
sqlMethod: normalizeSqlMethod(querySpec.method),
|
||||
err: typeof err === 'object' ? omit(err, 'detail') : err,
|
||||
region,
|
||||
sql: data.sql,
|
||||
sqlMethod: normalizeSqlMethod(data.method),
|
||||
sqlQueryId: queryId,
|
||||
sqlQueryDurationMs: toNDecimalPlaces(durationMs, 0),
|
||||
sqlNumberBindings: querySpec.bindings?.length || -1
|
||||
sqlNumberBindings: data.bindings?.length || -1
|
||||
},
|
||||
'DB query errored for {sqlMethod} after {sqlQueryDurationMs}ms'
|
||||
)
|
||||
})
|
||||
|
||||
const pool = params.db.client.pool
|
||||
const pool = db.client.pool
|
||||
|
||||
// configure hooks on knex connection pool
|
||||
pool.on('acquireRequest', (eventId: number) => {
|
||||
@@ -190,7 +270,8 @@ export const initKnexPrometheusMetrics = (params: {
|
||||
const now = performance.now()
|
||||
const durationMs = now - connectionAcquisitionStartTime[eventId]
|
||||
delete connectionAcquisitionStartTime[eventId]
|
||||
if (!isNaN(durationMs)) metricConnectionAcquisitionDuration.observe(durationMs)
|
||||
if (!isNaN(durationMs))
|
||||
metricConnectionAcquisitionDuration.labels({ region }).observe(durationMs)
|
||||
|
||||
// successful acquisition is the start of usage, so record that start time
|
||||
let knexUid: string | undefined = undefined
|
||||
@@ -234,7 +315,8 @@ export const initKnexPrometheusMetrics = (params: {
|
||||
|
||||
const now = performance.now()
|
||||
const durationMs = now - connectionInUseStartTime[knexUid]
|
||||
if (!isNaN(durationMs)) metricConnectionInUseDuration.observe(durationMs)
|
||||
if (!isNaN(durationMs))
|
||||
metricConnectionInUseDuration.labels({ region }).observe(durationMs)
|
||||
// params.logger.debug(
|
||||
// {
|
||||
// knexUid,
|
||||
@@ -263,7 +345,8 @@ export const initKnexPrometheusMetrics = (params: {
|
||||
pool.on('stopReaping', () => {
|
||||
if (!reapingStartTime) return
|
||||
const durationMs = performance.now() - reapingStartTime
|
||||
if (!isNaN(durationMs)) metricConnectionPoolReapingDuration.observe(durationMs)
|
||||
if (!isNaN(durationMs))
|
||||
metricConnectionPoolReapingDuration.labels({ region }).observe(durationMs)
|
||||
reapingStartTime = undefined
|
||||
})
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ export const convertLegacyDataToStateFactory =
|
||||
isOrthoProjection: !!data.camPos?.[6],
|
||||
zoom: data.camPos?.[7] || 1
|
||||
},
|
||||
viewMode: 0,
|
||||
sectionBox: sectionBox
|
||||
? {
|
||||
min: (sectionBox.min as number[]) || [0, 0, 0],
|
||||
|
||||
@@ -57,7 +57,7 @@ export function buildNotificationsStateTracker() {
|
||||
* Wait for an acknowledgement of a specific msg
|
||||
*/
|
||||
waitForMsgAck: async (msgId: JobId, timeout = 2000) => {
|
||||
let timeoutRef: NodeJS.Timer
|
||||
let timeoutRef: NodeJS.Timeout
|
||||
let eventEmitterHandler: (e: AckEvent) => void
|
||||
return new Promise<AckEvent>((resolve, reject) => {
|
||||
// Set ack cb for notifications event handler
|
||||
|
||||
@@ -74,6 +74,7 @@ export type SerializedViewerState = {
|
||||
isOrthoProjection: boolean
|
||||
zoom: number
|
||||
}
|
||||
viewMode: number
|
||||
sectionBox: Nullable<{
|
||||
min: number[]
|
||||
max: number[]
|
||||
@@ -198,6 +199,7 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState =
|
||||
isOrthoProjection: state.ui?.camera?.isOrthoProjection || false,
|
||||
zoom: state.ui?.camera?.zoom || 1
|
||||
},
|
||||
viewMode: state.ui?.viewMode || 0,
|
||||
sectionBox:
|
||||
state.ui?.sectionBox?.min?.length && state.ui?.sectionBox.max?.length
|
||||
? {
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
ref="searchInput"
|
||||
v-model="searchValue"
|
||||
type="text"
|
||||
class="py-1 pl-7 w-full bg-foundation placeholder:font-normal normal placeholder:text-foreground-2 text-[13px]"
|
||||
class="py-1 pl-7 w-full bg-foundation placeholder:font-normal normal placeholder:text-foreground-2 text-[13px] focus-visible:[box-shadow:none] rounded-md hover:border-outline-5 focus-visible:border-outline-4"
|
||||
:placeholder="searchPlaceholder"
|
||||
@keydown.stop
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ToastNotificationType } from '~~/src/helpers/global/toast'
|
||||
import type { ToastNotification } from '~~/src/helpers/global/toast'
|
||||
import { useGlobalToast } from '~~/src/stories/composables/toast'
|
||||
|
||||
type StoryType = StoryObj<{ notification: ToastNotification }>
|
||||
type StoryType = StoryObj<{ notifications: ToastNotification[] }>
|
||||
|
||||
export default {
|
||||
component: ToastRenderer,
|
||||
@@ -20,12 +20,11 @@ export default {
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
notification: {
|
||||
description: 'ToastNotification type object, nullable'
|
||||
notifications: {
|
||||
description: 'ToastNotification array, nullable'
|
||||
},
|
||||
'update:notification': {
|
||||
description:
|
||||
"Notification prop update event. Enables two-way binding through 'v-model:notification'"
|
||||
dismiss: {
|
||||
description: 'Dismiss event for a notification'
|
||||
}
|
||||
}
|
||||
} as Meta
|
||||
@@ -35,11 +34,11 @@ export const Default: StoryType = {
|
||||
components: { ToastRenderer, FormButton },
|
||||
setup() {
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const notification = ref(null as Nullable<ToastNotification>)
|
||||
const notifications = ref(null as Nullable<ToastNotification[]>)
|
||||
const onClick = () => {
|
||||
triggerNotification(args.notification)
|
||||
triggerNotification(args.notifications[0])
|
||||
}
|
||||
return { args, onClick, notification }
|
||||
return { args, onClick, notifications }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
@@ -50,30 +49,34 @@ export const Default: StoryType = {
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
code: '<GlobalToastRenderer v-model:notification="notification"/>'
|
||||
code: '<GlobalToastRenderer v-model:notifications="notifications" />'
|
||||
}
|
||||
}
|
||||
},
|
||||
args: {
|
||||
notification: {
|
||||
type: ToastNotificationType.Info,
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
notifications: [
|
||||
{
|
||||
type: ToastNotificationType.Info,
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
|
||||
cta: {
|
||||
title: 'CTA'
|
||||
cta: {
|
||||
title: 'CTA'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export const WithManualClose: StoryType = {
|
||||
...Default,
|
||||
args: {
|
||||
notification: {
|
||||
...Default.args!.notification!,
|
||||
autoClose: false
|
||||
}
|
||||
notifications: [
|
||||
{
|
||||
...Default.args!.notifications![0],
|
||||
autoClose: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,23 +84,20 @@ export const NoCtaOrDescription: StoryObj = {
|
||||
render: (args) => ({
|
||||
components: { ToastRenderer, FormButton },
|
||||
setup() {
|
||||
const notification = ref(null as Nullable<ToastNotification>)
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const notifications = ref(null as Nullable<ToastNotification[]>)
|
||||
const onClick = () => {
|
||||
// Update notification without cta or description
|
||||
notification.value = {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Info,
|
||||
title: 'Displays a toast notification'
|
||||
}
|
||||
|
||||
// Clear after 2s
|
||||
setTimeout(() => (notification.value = null), 2000)
|
||||
})
|
||||
}
|
||||
return { args, onClick, notification }
|
||||
return { args, onClick, notifications }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<FormButton @click="onClick">Trigger Title Only</FormButton>
|
||||
<ToastRenderer v-model:notification="notification"/>
|
||||
<ToastRenderer v-model:notifications="notifications" />
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
@@ -108,8 +108,8 @@ export const NoCtaOrDescription: StoryObj = {
|
||||
},
|
||||
source: {
|
||||
code: `
|
||||
<FormButton @click="onClick">Trigger Title Only</FormButton>
|
||||
<ToastRenderer v-model:notification="notification"/>
|
||||
<FormButton @click="onClick">Trigger Title Only</FormButton>
|
||||
<ToastRenderer v-model:notifications="notifications" />
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
>
|
||||
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||
<!-- Notification panel, dynamically insert this into the live region when it needs to be displayed -->
|
||||
<Transition
|
||||
<TransitionGroup
|
||||
enter-active-class="transform ease-out duration-300 transition"
|
||||
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
|
||||
@@ -14,9 +14,10 @@
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="notification"
|
||||
v-for="(notification, index) in notifications"
|
||||
:key="`toast-${index}`"
|
||||
class="pointer-events-auto w-full max-w-[20rem] overflow-hidden rounded bg-foundation text-foreground shadow-lg border border-outline-2 p-3"
|
||||
:class="{ 'pb-2': isTitleOnly }"
|
||||
:class="{ 'pb-2': !notification?.description && !notification?.cta }"
|
||||
>
|
||||
<div class="flex space-x-2">
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
@@ -60,7 +61,7 @@
|
||||
color="subtle"
|
||||
:to="notification.cta.url"
|
||||
size="sm"
|
||||
@click="onCtaClick"
|
||||
@click="(e: MouseEvent) => onCtaClick(notification, e)"
|
||||
>
|
||||
{{ notification.cta.title }}
|
||||
</FormButton>
|
||||
@@ -70,7 +71,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex rounded-md bg-foundation text-foreground-2 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
@click="dismiss"
|
||||
@click="dismiss(notification)"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<XMarkIcon class="h-5 w-5" aria-hidden="true" />
|
||||
@@ -78,7 +79,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -91,29 +92,24 @@ import {
|
||||
InformationCircleIcon,
|
||||
XMarkIcon
|
||||
} from '@heroicons/vue/20/solid'
|
||||
import { computed } from 'vue'
|
||||
import type { MaybeNullOrUndefined } from '@speckle/shared'
|
||||
import { ToastNotificationType } from '~~/src/helpers/global/toast'
|
||||
import type { ToastNotification } from '~~/src/helpers/global/toast'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:notification', val: MaybeNullOrUndefined<ToastNotification>): void
|
||||
(e: 'dismiss', val: ToastNotification): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
notification: MaybeNullOrUndefined<ToastNotification>
|
||||
defineProps<{
|
||||
notifications: MaybeNullOrUndefined<ToastNotification[]>
|
||||
}>()
|
||||
|
||||
const isTitleOnly = computed(
|
||||
() => !props.notification?.description && !props.notification?.cta
|
||||
)
|
||||
|
||||
const dismiss = () => {
|
||||
emit('update:notification', null)
|
||||
const dismiss = (notification: ToastNotification) => {
|
||||
emit('dismiss', notification)
|
||||
}
|
||||
|
||||
const onCtaClick = (e: MouseEvent) => {
|
||||
props.notification?.cta?.onClick?.(e)
|
||||
dismiss()
|
||||
const onCtaClick = (notification: ToastNotification, e: MouseEvent) => {
|
||||
notification.cta?.onClick?.(e)
|
||||
dismiss(notification)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,14 +9,14 @@ export enum ModifierKeys {
|
||||
export const clientOs = getClientOperatingSystem()
|
||||
|
||||
export const ModifierKeyTitles: Record<ModifierKeys, string> = {
|
||||
[ModifierKeys.CtrlOrCmd]: clientOs === OperatingSystem.Mac ? 'Cmd' : 'Ctrl',
|
||||
[ModifierKeys.AltOrOpt]: clientOs === OperatingSystem.Mac ? 'Opt' : 'Alt',
|
||||
[ModifierKeys.Shift]: 'Shift'
|
||||
[ModifierKeys.CtrlOrCmd]: clientOs === OperatingSystem.Mac ? '⌘' : '⌃',
|
||||
[ModifierKeys.AltOrOpt]: clientOs === OperatingSystem.Mac ? '⌥' : 'Alt',
|
||||
[ModifierKeys.Shift]: '⇧'
|
||||
}
|
||||
|
||||
export function getKeyboardShortcutTitle(keys: Array<string | ModifierKeys>) {
|
||||
const isModifierKey = (k: string): k is ModifierKeys =>
|
||||
(Object.values(ModifierKeys) as string[]).includes(k)
|
||||
|
||||
return keys.map((v) => (isModifierKey(v) ? ModifierKeyTitles[v] : v)).join('+')
|
||||
return keys.map((v) => (isModifierKey(v) ? ModifierKeyTitles[v] : v)).join('')
|
||||
}
|
||||
|
||||
@@ -25,4 +25,5 @@ export type ToastNotification = {
|
||||
* Defaults to true
|
||||
*/
|
||||
autoClose?: boolean
|
||||
id?: string
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<ToastRenderer v-model:notification="notification" />
|
||||
<ToastRenderer v-model:notifications="notifications" @dismiss="dismiss" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import ToastRenderer from '~~/src/components/global/ToastRenderer.vue'
|
||||
import { useGlobalToastManager } from '~~/src/stories/composables/toast'
|
||||
|
||||
const { currentNotification, dismiss } = useGlobalToastManager()
|
||||
const { currentNotifications, dismissAll, dismiss } = useGlobalToastManager()
|
||||
|
||||
const notification = computed({
|
||||
get: () => currentNotification.value,
|
||||
const notifications = computed({
|
||||
get: () => currentNotifications.value,
|
||||
set: (newVal) => {
|
||||
if (!newVal) {
|
||||
dismiss()
|
||||
dismissAll()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,57 +1,94 @@
|
||||
import { useTimeoutFn, createGlobalState } from '@vueuse/core'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import type { ToastNotification } from '~~/src/helpers/global/toast'
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import { useTimeoutFn, createGlobalState } from '@vueuse/core'
|
||||
import { computed, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
/**
|
||||
* Development-only version of the toast notification state. Do not export this out from the library as it can't work in SSR!
|
||||
*/
|
||||
|
||||
const useGlobalToastState = createGlobalState(() =>
|
||||
ref(null as Nullable<ToastNotification>)
|
||||
ref([] as Optional<ToastNotification[]>)
|
||||
)
|
||||
|
||||
/**
|
||||
* Set up a new global toast manager/renderer (don't use this in multiple components that live at the same time)
|
||||
*/
|
||||
export function useGlobalToastManager() {
|
||||
type Timeout = {
|
||||
id: string
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
const stateNotification = useGlobalToastState()
|
||||
|
||||
const currentNotification = ref(stateNotification.value)
|
||||
const readOnlyNotification = computed(() => currentNotification.value)
|
||||
const timeouts = ref<Timeout[]>([])
|
||||
const currentNotifications = ref<ToastNotification[]>(
|
||||
Array.isArray(stateNotification.value) ? stateNotification.value : []
|
||||
)
|
||||
const readOnlyNotification = computed(() => currentNotifications.value)
|
||||
|
||||
const { start, stop } = useTimeoutFn(() => {
|
||||
dismiss()
|
||||
}, 4000)
|
||||
// Remove a specific notification from the state
|
||||
const removeNotification = (id: string) => {
|
||||
const index = currentNotifications.value.findIndex((n) => n.id === id)
|
||||
if (index !== -1) {
|
||||
currentNotifications.value.splice(index, 1)
|
||||
// Clean up timeout
|
||||
timeouts.value = timeouts.value.filter((t) => t.id !== id)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a timeout for a notification
|
||||
const createTimeout = (notification: ToastNotification) => {
|
||||
const { stop } = useTimeoutFn(() => {
|
||||
if (notification.id) {
|
||||
removeNotification(notification.id)
|
||||
}
|
||||
}, 4000)
|
||||
return stop
|
||||
}
|
||||
|
||||
watch(
|
||||
stateNotification,
|
||||
async (newVal) => {
|
||||
(newVal) => {
|
||||
if (!newVal) return
|
||||
currentNotifications.value = newVal
|
||||
|
||||
// First dismiss old notification, then set a new one on next tick
|
||||
// this is so that the old one actually disappears from the screen for the user,
|
||||
// instead of just having its contents replaced
|
||||
dismiss()
|
||||
// Create timeout for the new notification
|
||||
const index = currentNotifications.value.length - 1
|
||||
const lastNotification = newVal[index]
|
||||
|
||||
await nextTick(() => {
|
||||
currentNotification.value = newVal
|
||||
|
||||
// (re-)init timeout
|
||||
stop()
|
||||
if (newVal.autoClose !== false) start()
|
||||
})
|
||||
if (lastNotification && !lastNotification.autoClose) {
|
||||
timeouts.value.push({
|
||||
id: lastNotification.id as string,
|
||||
stop: createTimeout(lastNotification)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
const dismiss = () => {
|
||||
currentNotification.value = null
|
||||
stateNotification.value = null
|
||||
// Function to dismiss a specific notification
|
||||
const dismiss = (notification: ToastNotification) => {
|
||||
if (!notification.id) return
|
||||
|
||||
const targetTimeout = timeouts.value.find((t) => t.id === notification.id)
|
||||
if (targetTimeout) {
|
||||
targetTimeout.stop()
|
||||
}
|
||||
removeNotification(notification.id)
|
||||
}
|
||||
|
||||
return { currentNotification: readOnlyNotification, dismiss }
|
||||
// Dismiss all notifications
|
||||
const dismissAll = () => {
|
||||
timeouts.value.forEach((timeout) => timeout.stop())
|
||||
timeouts.value = []
|
||||
currentNotifications.value = []
|
||||
}
|
||||
|
||||
return { currentNotifications: readOnlyNotification, dismiss, dismissAll }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +101,11 @@ export function useGlobalToast() {
|
||||
* Trigger a new toast notification
|
||||
*/
|
||||
const triggerNotification = (notification: ToastNotification) => {
|
||||
stateNotification.value = notification
|
||||
const newNotification = { ...notification, id: nanoid() }
|
||||
|
||||
stateNotification.value
|
||||
? stateNotification.value.push(newNotification)
|
||||
: (stateNotification.value = [newNotification])
|
||||
}
|
||||
|
||||
return { triggerNotification }
|
||||
|
||||
@@ -138,6 +138,6 @@ export class ExtendedSelection extends SelectionExtension {
|
||||
)
|
||||
}
|
||||
this.lastGizmoTranslation.copy(this.dummyAnchor.position)
|
||||
this.viewer.requestRender(UpdateFlags.RENDER | UpdateFlags.SHADOWS)
|
||||
this.viewer.requestRender(UpdateFlags.RENDER_RESET | UpdateFlags.SHADOWS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ export class ViewModesKeys extends Extension {
|
||||
case '5':
|
||||
viewModes.setViewMode(ViewMode.ARCTIC)
|
||||
break
|
||||
case '6':
|
||||
viewModes.setViewMode(ViewMode.COLORS)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import './style.css'
|
||||
import Sandbox from './Sandbox'
|
||||
import {
|
||||
SelectionExtension,
|
||||
MeasurementsExtension,
|
||||
ExplodeExtension,
|
||||
DiffExtension,
|
||||
@@ -21,6 +20,7 @@ import { SectionTool } from '@speckle/viewer'
|
||||
import { SectionOutlines } from '@speckle/viewer'
|
||||
import { ViewModesKeys } from './Extensions/ViewModesKeys'
|
||||
import { BoxSelection } from './Extensions/BoxSelection'
|
||||
import { ExtendedSelection } from './Extensions/ExtendedSelection'
|
||||
|
||||
const createViewer = async (containerName: string, stream: string) => {
|
||||
const container = document.querySelector<HTMLElement>(containerName)
|
||||
@@ -45,7 +45,8 @@ const createViewer = async (containerName: string, stream: string) => {
|
||||
await viewer.init()
|
||||
|
||||
const cameraController = viewer.createExtension(CameraController)
|
||||
const selection = viewer.createExtension(SelectionExtension)
|
||||
const selection = viewer.createExtension(ExtendedSelection)
|
||||
selection.init()
|
||||
const sections = viewer.createExtension(SectionTool)
|
||||
viewer.createExtension(SectionOutlines)
|
||||
const measurements = viewer.createExtension(MeasurementsExtension)
|
||||
@@ -115,7 +116,7 @@ const getStream = () => {
|
||||
// 'https://app.speckle.systems/streams/da9e320dad/commits/5388ef24b8'
|
||||
// 'https://latest.speckle.systems/streams/58b5648c4d/commits/60371ecb2d'
|
||||
// 'Super' heavy revit shit
|
||||
'https://app.speckle.systems/streams/e6f9156405/commits/0694d53bb5'
|
||||
// 'https://app.speckle.systems/streams/e6f9156405/commits/0694d53bb5'
|
||||
// IFC building (good for a tree based structure)
|
||||
// 'https://latest.speckle.systems/streams/92b620fb17/commits/2ebd336223'
|
||||
// IFC story, a subtree of the above
|
||||
@@ -454,6 +455,12 @@ const getStream = () => {
|
||||
// 'https://app.speckle.systems/projects/344f803f81/models/5582ab673e'
|
||||
|
||||
// 'https://speckle.xyz/streams/27e89d0ad6/commits/5ed4b74252'
|
||||
|
||||
// DUI3 Mesh Colors
|
||||
'https://app.speckle.systems/projects/93200a735d/models/cbacd3eaeb@344a397239'
|
||||
|
||||
// Instance toilets
|
||||
// 'https://app.speckle.systems/projects/e89b61b65c/models/2a0995f124'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,8 @@ import {
|
||||
} from './modules/materials/Materials.js'
|
||||
import { AccelerationStructure } from './modules/objects/AccelerationStructure.js'
|
||||
import { TopLevelAccelerationStructure } from './modules/objects/TopLevelAccelerationStructure.js'
|
||||
import { ViewModeEvent, ViewModeEventPayload } from './modules/extensions/ViewModes.js'
|
||||
import { BasitPipeline } from './modules/pipeline/Pipelines/BasitViewPipeline.js'
|
||||
|
||||
export {
|
||||
Viewer,
|
||||
@@ -214,6 +216,7 @@ export {
|
||||
MRTEdgesPipeline,
|
||||
MRTShadedViewPipeline,
|
||||
MRTPenViewPipeline,
|
||||
BasitPipeline,
|
||||
ViewModes,
|
||||
ViewMode,
|
||||
FilterMaterial,
|
||||
@@ -221,7 +224,8 @@ export {
|
||||
FilterMaterialOptions,
|
||||
NOT_INTERSECTED,
|
||||
INTERSECTED,
|
||||
CONTAINED
|
||||
CONTAINED,
|
||||
ViewModeEvent
|
||||
}
|
||||
|
||||
export type {
|
||||
@@ -255,7 +259,8 @@ export type {
|
||||
SectionToolEventPayload,
|
||||
CameraEventPayload,
|
||||
SelectionExtensionOptions,
|
||||
DefaultSelectionExtensionOptions
|
||||
DefaultSelectionExtensionOptions,
|
||||
ViewModeEventPayload
|
||||
}
|
||||
|
||||
export * as UrlHelper from './modules/UrlHelper.js'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { UpdateFlags } from '../../IViewer.js'
|
||||
import { ArcticViewPipeline } from '../pipeline/Pipelines/ArcticViewPipeline.js'
|
||||
import { BasitPipeline } from '../pipeline/Pipelines/BasitViewPipeline.js'
|
||||
import { DefaultPipeline } from '../pipeline/Pipelines/DefaultPipeline.js'
|
||||
import { EdgesPipeline } from '../pipeline/Pipelines/EdgesPipeline.js'
|
||||
import { MRTEdgesPipeline } from '../pipeline/Pipelines/MRT/MRTEdgesPipeline.js'
|
||||
@@ -14,10 +15,26 @@ export enum ViewMode {
|
||||
DEFAULT_EDGES,
|
||||
SHADED,
|
||||
PEN,
|
||||
ARCTIC
|
||||
ARCTIC,
|
||||
COLORS
|
||||
}
|
||||
|
||||
export enum ViewModeEvent {
|
||||
Changed = 'view-mode-changed'
|
||||
}
|
||||
|
||||
export interface ViewModeEventPayload {
|
||||
[ViewModeEvent.Changed]: ViewMode
|
||||
}
|
||||
|
||||
export class ViewModes extends Extension {
|
||||
public on<T extends ViewModeEvent>(
|
||||
eventType: T,
|
||||
listener: (arg: ViewModeEventPayload[T]) => void
|
||||
): void {
|
||||
super.on(eventType, listener)
|
||||
}
|
||||
|
||||
public setViewMode(viewMode: ViewMode) {
|
||||
const renderer = this.viewer.getRenderer()
|
||||
const isMRTCapable =
|
||||
@@ -46,7 +63,12 @@ export class ViewModes extends Extension {
|
||||
case ViewMode.ARCTIC:
|
||||
renderer.pipeline = new ArcticViewPipeline(renderer)
|
||||
break
|
||||
case ViewMode.COLORS:
|
||||
renderer.pipeline = new BasitPipeline(renderer, this.viewer.getWorldTree())
|
||||
break
|
||||
}
|
||||
this.viewer.requestRender(UpdateFlags.RENDER_RESET)
|
||||
|
||||
this.emit(ViewModeEvent.Changed, viewMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +226,10 @@ void main() {
|
||||
vec4 rtePivotShadow = computeRelativePositionSeparate(tPivotLow.xyz, tPivotHigh.xyz, uShadowViewer_low, uShadowViewer_high);
|
||||
shadowPosition.xyz = rotate_vertex_position((shadowPosition - rtePivotShadow).xyz, tQuaternion) * tScale.xyz + rtePivotShadow.xyz + tTranslation.xyz;
|
||||
#endif
|
||||
#ifdef USE_INSTANCING
|
||||
vec4 rtePivotShadow = computeRelativePositionSeparate(ZERO3, ZERO3, uShadowViewer_low, uShadowViewer_high);
|
||||
shadowPosition.xyz = (mat3(instanceMatrix) * (shadowPosition - rtePivotShadow).xyz) + rtePivotShadow.xyz + instanceMatrix[3].xyz;
|
||||
#endif
|
||||
shadowWorldPosition = modelMatrix * shadowPosition + vec4( shadowWorldNormal * directionalLightShadows[ i ].shadowNormalBias, 0 );
|
||||
vDirectionalShadowCoord[ i ] = shadowMatrix * shadowWorldPosition;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Object3D,
|
||||
Ray,
|
||||
Raycaster,
|
||||
RGBADepthPacking,
|
||||
SkinnedMesh,
|
||||
Sphere,
|
||||
Triangle,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
} from '../batching/Batch.js'
|
||||
import { SpeckleRaycaster } from './SpeckleRaycaster.js'
|
||||
import Logger from '../utils/Logger.js'
|
||||
import SpeckleDepthMaterial from '../materials/SpeckleDepthMaterial.js'
|
||||
|
||||
const _inverseMatrix = new Matrix4()
|
||||
const _ray = new Ray()
|
||||
@@ -183,6 +185,7 @@ export default class SpeckleInstancedMesh extends Group {
|
||||
public updateDrawGroups(transformBuffer: Float32Array, gradientBuffer: Float32Array) {
|
||||
this.instances.forEach((value: InstancedMesh) => {
|
||||
this.remove(value)
|
||||
value.customDepthMaterial?.dispose()
|
||||
value.dispose()
|
||||
})
|
||||
this.instances.length = 0
|
||||
@@ -218,6 +221,14 @@ export default class SpeckleInstancedMesh extends Group {
|
||||
group.instanceMatrix.needsUpdate = true
|
||||
group.layers.set(ObjectLayers.STREAM_CONTENT_MESH)
|
||||
group.frustumCulled = false
|
||||
group.customDepthMaterial = new SpeckleDepthMaterial(
|
||||
{
|
||||
depthPacking: RGBADepthPacking
|
||||
},
|
||||
['USE_RTE', 'ALPHATEST_REJECTION']
|
||||
)
|
||||
group.castShadow = !material.transparent
|
||||
group.receiveShadow = !material.transparent
|
||||
|
||||
this.instances.push(group)
|
||||
this.add(group)
|
||||
|
||||
@@ -32,13 +32,14 @@ export class BasitPass extends BaseGPass {
|
||||
super()
|
||||
this.tree = tree
|
||||
this.speckleRenderer = renderer
|
||||
this.buildMaterials()
|
||||
}
|
||||
|
||||
public get displayName(): string {
|
||||
return 'BASIT'
|
||||
}
|
||||
|
||||
onBeforeRender = () => {
|
||||
protected buildMaterials() {
|
||||
const batches: MeshBatch[] = this.speckleRenderer.batcher.getBatches(
|
||||
undefined,
|
||||
GeometryType.MESH
|
||||
@@ -112,14 +113,14 @@ export class BasitPass extends BaseGPass {
|
||||
protected overrideMaterials() {
|
||||
for (const k in this.materialMap) {
|
||||
const tuple = this.materialMap[k]
|
||||
;(tuple[0].renderObject as SpeckleMesh).setOverrideMaterial(tuple[2])
|
||||
;(tuple[0].renderObject as SpeckleMesh).setOverrideBatchMaterial(tuple[2])
|
||||
}
|
||||
}
|
||||
|
||||
protected restoreMaterials() {
|
||||
for (const k in this.materialMap) {
|
||||
const tuple = this.materialMap[k]
|
||||
;(tuple[0].renderObject as SpeckleMesh).restoreMaterial()
|
||||
;(tuple[0].renderObject as SpeckleMesh).restoreBatchMaterial()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import SpeckleRenderer from '../../SpeckleRenderer.js'
|
||||
import { GeometryPass } from '../Passes/GeometryPass.js'
|
||||
import { Pipeline } from './Pipeline.js'
|
||||
import { BasitPass } from '../Passes/BasitPass.js'
|
||||
import { ClearFlags } from '../Passes/GPass.js'
|
||||
import { ClearFlags, ObjectVisibility } from '../Passes/GPass.js'
|
||||
import { StencilPass } from '../Passes/StencilPass.js'
|
||||
import { StencilMaskPass } from '../Passes/StencilMaskPass.js'
|
||||
|
||||
export class BasitPipeline extends Pipeline {
|
||||
constructor(speckleRenderer: SpeckleRenderer, tree: WorldTree) {
|
||||
@@ -12,13 +14,38 @@ export class BasitPipeline extends Pipeline {
|
||||
const basitPass = new BasitPass(tree, speckleRenderer)
|
||||
basitPass.setLayers([ObjectLayers.STREAM_CONTENT_MESH])
|
||||
basitPass.setClearColor(0x000000, 0)
|
||||
basitPass.setClearFlags(ClearFlags.COLOR | ClearFlags.DEPTH | ClearFlags.STENCIL)
|
||||
basitPass.setClearFlags(ClearFlags.COLOR)
|
||||
basitPass.outputTarget = null
|
||||
|
||||
const transparentColorPass = new GeometryPass()
|
||||
transparentColorPass.setLayers([ObjectLayers.SHADOWCATCHER])
|
||||
transparentColorPass.outputTarget = null
|
||||
const nonMeshPass = new GeometryPass()
|
||||
nonMeshPass.setLayers([
|
||||
ObjectLayers.STREAM_CONTENT_LINE,
|
||||
ObjectLayers.STREAM_CONTENT_POINT,
|
||||
ObjectLayers.STREAM_CONTENT_POINT_CLOUD,
|
||||
ObjectLayers.STREAM_CONTENT_TEXT
|
||||
])
|
||||
const stencilPass = new StencilPass()
|
||||
stencilPass.setVisibility(ObjectVisibility.STENCIL)
|
||||
stencilPass.setLayers([ObjectLayers.STREAM_CONTENT_MESH])
|
||||
|
||||
this.passList.push(basitPass, transparentColorPass)
|
||||
const stencilMaskPass = new StencilMaskPass()
|
||||
stencilMaskPass.setVisibility(ObjectVisibility.STENCIL)
|
||||
stencilMaskPass.setLayers([ObjectLayers.STREAM_CONTENT_MESH])
|
||||
stencilMaskPass.setClearFlags(ClearFlags.DEPTH)
|
||||
|
||||
const overlayPass = new GeometryPass()
|
||||
overlayPass.setLayers([
|
||||
ObjectLayers.PROPS,
|
||||
ObjectLayers.OVERLAY,
|
||||
ObjectLayers.MEASUREMENTS
|
||||
])
|
||||
|
||||
this.passList.push(
|
||||
stencilPass,
|
||||
basitPass,
|
||||
nonMeshPass,
|
||||
stencilMaskPass,
|
||||
overlayPass
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user