Merge branch 'feature/initial-viewer-ui-updates' of https://github.com/specklesystems/speckle-server into feature/initial-viewer-ui-updates

This commit is contained in:
andrewwallacespeckle
2025-07-30 15:31:19 +02:00
5 changed files with 184 additions and 82 deletions
@@ -63,26 +63,26 @@
@click="emit('next', modelValue)"
/>
</div>
<FormButton
v-show="isDragged"
v-tippy="'Pop in'"
:icon-left="ArrowTopRightOnSquareIcon"
hide-text
class="rotate-180"
color="subtle"
size="sm"
@click="isDragged = false"
/>
</div>
<div class="flex gap-x-0.5">
<FormButton
v-tippy="'Copy link'"
:icon-left="LinkIcon"
hide-text
color="subtle"
size="sm"
@click="onCopyLink"
/>
<div class="cursor-pointer">
<LayoutMenu
v-model:open="showMenu"
:menu-id="menuId"
:items="actionsItems"
mount-menu-on-body
@click.stop.prevent
@chosen="onActionChosen"
>
<FormButton
hide-text
size="sm"
color="subtle"
:icon-left="iconThreeDots"
@click="showMenu = !showMenu"
/>
</LayoutMenu>
</div>
<FormButton
v-tippy="modelValue.archived ? 'Unresolve' : 'Resolve'"
:icon-left="IconCircleCheck"
@@ -172,10 +172,8 @@
</template>
<script setup lang="ts">
import {
LinkIcon,
ChevronLeftIcon,
ChevronRightIcon,
ArrowTopRightOnSquareIcon,
ArrowLeftIcon,
ArrowUpRightIcon
} from '@heroicons/vue/24/outline'
@@ -202,6 +200,12 @@ import { useThreadUtilities } from '~~/lib/viewer/composables/ui'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
import { graphql } from '~/lib/common/generated/gql'
import type { ConcreteComponent } from 'vue'
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
enum ActionTypes {
CopyLink = 'copy-link',
PopIn = 'pop-in'
}
graphql(`
fragment ViewerCommentThreadData on Comment {
@@ -235,7 +239,6 @@ const { copy } = useClipboard()
const { isLoggedIn } = useActiveUser()
const archiveComment = useArchiveComment()
const { triggerNotification } = useGlobalToast()
const { projectId } = useInjectedViewerState()
const canReply = useCheckViewerCommentingAccess()
const { disableTextSelection } = useDisableGlobalTextSelection()
@@ -246,10 +249,13 @@ const { threadResourceStatus, hasClickedFullContext, goBack, handleContextClick
useCommentContext()
const { isOpenThread, open, closeAllThreads } = useThreadUtilities()
const router = useRouter()
const menuId = useId()
const showMenu = ref(false)
const commentsContainer = ref(null as Nullable<HTMLElement>)
const threadContainer = ref(null as Nullable<HTMLElement>)
const threadActivator = ref(null as Nullable<HTMLElement>)
const iconThreeDots = resolveComponent('IconThreeDots') as ConcreteComponent
onClickOutside(threadContainer, (event) => {
const viewerElement = document.getElementById('viewer')
@@ -382,6 +388,37 @@ const threadAuthors = computed(() => {
return authors
})
const actionsItems = computed<LayoutMenuItem[][]>(() => [
[
{
title: 'Copy link',
id: ActionTypes.CopyLink
},
{
title: 'Pop in',
id: ActionTypes.PopIn,
disabled: !isDragged.value
}
]
])
const canArchiveOrUnarchive = computed(
() => props.modelValue.permissions.canArchive.authorized
)
const onActionChosen = (params: { item: LayoutMenuItem; event: MouseEvent }) => {
const { item } = params
switch (item.id) {
case ActionTypes.CopyLink:
onCopyLink()
break
case ActionTypes.PopIn:
isDragged.value = false
break
}
}
const changeExpanded = async (newVal: boolean) => {
if (newVal) {
await open(props.modelValue.id)
@@ -398,10 +435,6 @@ const changeExpanded = async (newVal: boolean) => {
})
}
const canArchiveOrUnarchive = computed(
() => props.modelValue.permissions.canArchive.authorized
)
const toggleCommentResolvedStatus = async () => {
// Remove thread ID from URL when resolving
if (!props.modelValue.archived) {
@@ -5,44 +5,26 @@
</template>
<template #actions>
<div class="relative pr-2">
<FormButton
ref="settingsButtonRef"
hide-text
size="sm"
color="subtle"
:icon-left="settingsIcon"
:class="showVisibilityOptions ? '!text-primary-focus !bg-info-lighter' : ''"
@click="showVisibilityOptions = !showVisibilityOptions"
/>
<ViewerLayoutPanel
v-if="showVisibilityOptions"
class="absolute right-2 top-full w-56 z-50"
<LayoutMenu
v-model:open="showVisibilityOptions"
:menu-id="menuId"
:items="actionsItems"
:menu-position="HorizontalDirection.Right"
mount-menu-on-body
:custom-menu-items-classes="['!w-[270px]']"
show-ticks
@click.stop.prevent
@chosen="onActionChosen"
>
<div class="p-1">
<ViewerMenuItem
label="Show in 3D model"
:active="!hideBubbles"
@click="hideBubbles = !hideBubbles"
/>
<ViewerMenuItem
:label="`Show resolved (${
commentThreadsMetadata?.totalArchivedCount || 0
})`"
:active="!!includeArchived"
@click="includeArchived = includeArchived ? undefined : 'includeArchived'"
/>
<ViewerMenuItem
label="Exclude threads from other versions"
:active="!!loadedVersionsOnly"
@click="
loadedVersionsOnly = loadedVersionsOnly
? undefined
: 'loadedVersionsOnly'
"
/>
</div>
</ViewerLayoutPanel>
<FormButton
hide-text
size="sm"
color="subtle"
:icon-left="settingsIcon"
:class="showVisibilityOptions ? '!text-primary-focus !bg-info-lighter' : ''"
@click="showVisibilityOptions = !showVisibilityOptions"
/>
</LayoutMenu>
</div>
</template>
<div class="flex flex-col">
@@ -77,6 +59,14 @@ import {
import { useMixpanel } from '~~/lib/core/composables/mp'
import { useCheckViewerCommentingAccess } from '~~/lib/viewer/composables/commentManagement'
import { useSelectionUtilities } from '~~/lib/viewer/composables/ui'
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
import { HorizontalDirection } from '~~/lib/common/composables/window'
enum ActionTypes {
HideBubbles = 'hide-bubbles',
IncludeArchived = 'include-archived',
LoadedVersionsOnly = 'loaded-versions-only'
}
graphql(`
fragment ViewerCommentsListItem on Comment {
@@ -117,6 +107,7 @@ const {
}
} = useInjectedViewerInterfaceState()
const canPostComment = useCheckViewerCommentingAccess()
const menuId = useId()
const showVisibilityOptions = ref(false)
const settingsIcon = resolveComponent('IconViewerSettings') as ConcreteComponent
@@ -159,7 +150,43 @@ watch(includeArchived, (newVal) =>
const { objectIds: selectedObjectIds } = useSelectionUtilities()
const hasSelectedObjects = computed(() => selectedObjectIds.value.size > 0)
const actionsItems = computed<LayoutMenuItem[][]>(() => [
[
{
title: 'Show in 3D model',
id: ActionTypes.HideBubbles,
active: !hideBubbles.value
},
{
title: `Show resolved (${commentThreadsMetadata.value?.totalArchivedCount || 0})`,
id: ActionTypes.IncludeArchived,
active: !!includeArchived.value
},
{
title: 'Exclude threads from other versions',
id: ActionTypes.LoadedVersionsOnly,
active: !!loadedVersionsOnly.value
}
]
])
const onActionChosen = (params: { item: LayoutMenuItem; event: MouseEvent }) => {
const { item } = params
switch (item.id) {
case ActionTypes.HideBubbles:
hideBubbles.value = !hideBubbles.value
break
case ActionTypes.IncludeArchived:
includeArchived.value = includeArchived.value ? undefined : 'includeArchived'
break
case ActionTypes.LoadedVersionsOnly:
loadedVersionsOnly.value = loadedVersionsOnly.value
? undefined
: 'loadedVersionsOnly'
break
}
}
const onNewDiscussion = () => {
if (!hasSelectedObjects.value) return
newThreadEditor.value = true
@@ -13,7 +13,7 @@
>
<template #title>
<div class="flex items-center gap-x-2">
<p>Selected objects</p>
<p>Selected</p>
<CommonBadge v-if="objects.length" rounded>
{{ objects.length }}
</CommonBadge>
@@ -24,25 +24,33 @@
<FormButton
size="sm"
color="subtle"
:icon-left="isHidden ? EyeSlashIcon : EyeIcon"
:icon-left="isHidden ? iconEyeClosed : iconEye"
hide-text
@click.stop="hideOrShowSelection"
/>
<FormButton
size="sm"
color="subtle"
:icon-left="isIsolated ? FunnelIcon : FunnelIconOutline"
:icon-left="isIsolated ? iconViewerUnisolate : iconViewerIsolate"
hide-text
@click.stop="isolateOrUnisolateSelection"
/>
<FormButton
size="sm"
color="subtle"
:icon-left="ArrowTopRightOnSquareIcon"
:to="selectionLink"
target="_blank"
hide-text
/>
<LayoutMenu
v-model:open="showSubMenu"
:menu-id="menuId"
:items="actionsItems"
:custom-menu-items-classes="['!w-48']"
@click.stop.prevent
@chosen="onActionChosen"
>
<FormButton
hide-text
size="sm"
color="subtle"
:icon-left="settingsIcon"
@click="showSubMenu = !showSubMenu"
/>
</LayoutMenu>
</div>
</template>
@@ -70,13 +78,6 @@
</ViewerCommentsPortalOrDiv>
</template>
<script setup lang="ts">
import {
EyeSlashIcon,
EyeIcon,
FunnelIcon,
ArrowTopRightOnSquareIcon
} from '@heroicons/vue/24/solid'
import { FunnelIcon as FunnelIconOutline } from '@heroicons/vue/24/outline'
import { onKeyStroke, useBreakpoints } from '@vueuse/core'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { getTargetObjectIds } from '~~/lib/object-sidebar/helpers'
@@ -87,6 +88,12 @@ import { useMixpanel } from '~~/lib/core/composables/mp'
import { useIsSmallerOrEqualThanBreakpoint } from '~~/composables/browser'
import { modelRoute } from '~/lib/common/helpers/route'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import type { ConcreteComponent } from 'vue'
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
enum ActionTypes {
OpenInNewTab = 'open-in-new-tab'
}
const {
projectId,
@@ -103,10 +110,18 @@ const { hideObjects, showObjects, isolateObjects, unIsolateObjects } =
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
const breakpoints = useBreakpoints(TailwindBreakpoints)
const isGreaterThanSm = breakpoints.greater('sm')
const menuId = useId()
const mp = useMixpanel()
const itemCount = ref(20)
const sidebarOpen = ref(false)
const sidebarWidth = ref(280)
const showSubMenu = ref(false)
const iconViewerUnisolate = resolveComponent('IconViewerUnisolate') as ConcreteComponent
const iconViewerIsolate = resolveComponent('IconViewerIsolate') as ConcreteComponent
const iconEyeClosed = resolveComponent('IconEyeClosed') as ConcreteComponent
const iconEye = resolveComponent('IconEye') as ConcreteComponent
const settingsIcon = resolveComponent('IconThreeDots') as ConcreteComponent
const objectsUniqueByAppId = computed(() => {
if (!diff.enabled.value) return objects.value
@@ -145,12 +160,29 @@ const isIsolated = computed(() => {
return containsAll(allTargetIds.value, isolatedObjects.value)
})
const mp = useMixpanel()
const actionsItems = computed<LayoutMenuItem[][]>(() => [
[
{
title: 'Open selection in new tab',
id: ActionTypes.OpenInNewTab
}
]
])
const selectionLink = computed(() => {
return modelRoute(projectId.value, allTargetIds.value.join(','))
})
const onActionChosen = (params: { item: LayoutMenuItem; event: MouseEvent }) => {
const { item } = params
switch (item.id) {
case ActionTypes.OpenInNewTab:
window.open(selectionLink.value, '_blank')
break
}
}
const hideOrShowSelection = () => {
if (!isHidden.value) {
hideObjects(allTargetIds.value)
@@ -29,6 +29,9 @@
@click="chooseItem(item, $event)"
>
<Component :is="item.icon" v-if="item.icon" class="h-4 w-4" />
<div v-if="showTicks" class="w-5 shrink-0">
<IconCheck v-if="item.active" class="h-4 w-4 text-foreground-2" />
</div>
<slot name="item" :item="item">{{ item.title }}</slot>
</button>
</span>
@@ -71,6 +74,8 @@ const props = defineProps<{
*/
menuPosition?: HorizontalDirection
mountMenuOnBody?: boolean
customMenuItemsClasses?: string[]
showTicks?: boolean
}>()
const menuItems = ref(null as Nullable<{ el: HTMLDivElement }>)
@@ -131,6 +136,10 @@ const menuItemsClasses = computed(() => {
'mt-1 w-44 origin-top-right divide-y divide-outline-3 rounded-md bg-foundation shadow-lg border border-outline-2 z-50'
]
if (props.customMenuItemsClasses) {
classParts.push(...props.customMenuItemsClasses)
}
if (props.mountMenuOnBody) {
classParts.push('fixed')
} else {
@@ -157,7 +166,7 @@ const buildButtonClassses = (params: {
}) => {
const { active, disabled, color } = params
const classParts = [
'group flex space-x-2 w-full items-center rounded-md px-2 py-1 text-body-xs'
'group flex space-x-2 w-full items-center rounded-md px-2 py-1 text-body-xs text-left'
]
if (active && !color) {
@@ -31,6 +31,7 @@ export type LayoutMenuItem<I extends string = string> = {
disabled?: boolean
disabledTooltip?: string
color?: 'danger' | 'info'
active?: boolean
}
export type LayoutDialogButton = {