feat(fe): new models panel first draft

This commit is contained in:
andrewwallacespeckle
2025-07-25 15:40:55 +01:00
parent 2c01eb97e5
commit 0d8cf29a66
14 changed files with 990 additions and 195 deletions
@@ -0,0 +1,17 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.33325 8H12.6666"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
@@ -6,6 +6,19 @@
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M8 3V13M3 8H13" stroke="currentColor" stroke-width="1.5" />
<path
d="M3.33325 8H12.6666"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8 3.33334V12.6667"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
@@ -7,10 +7,25 @@
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.5 8C14.5 11.5899 11.5899 14.5 8 14.5C4.41015 14.5 1.5 11.5899 1.5 8C1.5 4.41015 4.41015 1.5 8 1.5C11.5899 1.5 14.5 4.41015 14.5 8ZM16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8ZM8.75 3.75C8.75 3.33579 8.41421 3 8 3C7.58579 3 7.25 3.33579 7.25 3.75V8V8.31066L7.46967 8.53033L9.72358 10.7842C10.0165 11.0771 10.4913 11.0771 10.7842 10.7842C11.0771 10.4913 11.0771 10.0165 10.7842 9.72358L8.75 7.68934V3.75Z"
fill="currentColor"
d="M2 8C2 9.18669 2.35189 10.3467 3.01118 11.3334C3.67047 12.3201 4.60754 13.0892 5.7039 13.5433C6.80026 13.9974 8.00666 14.1162 9.17054 13.8847C10.3344 13.6532 11.4035 13.0818 12.2426 12.2426C13.0818 11.4035 13.6532 10.3344 13.8847 9.17054C14.1162 8.00666 13.9974 6.80026 13.5433 5.7039C13.0892 4.60754 12.3201 3.67047 11.3334 3.01118C10.3467 2.35189 9.18669 2 8 2C6.32263 2.00631 4.71265 2.66082 3.50667 3.82667L2 5.33333"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M2 2V5.33333H5.33333"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8 4.66666V7.99999L10.6667 9.33332"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
@@ -0,0 +1,229 @@
<template>
<ViewerLayoutSidePanel :title="showRemove ? 'Remove models' : 'Models'">
<template #actions>
<div class="flex items-center justify-between w-full">
<FormButton
v-if="showRemove"
size="sm"
color="subtle"
@click="showRemove = false"
>
<IconPlus class="rotate-45 text-foreground -ml-1 -mr-1" />
<span class="sr-only">Close</span>
</FormButton>
<div v-else class="flex gap-0.5">
<FormButton
v-if="resourceItems.length"
size="sm"
color="subtle"
@click="$emit('openVersions')"
>
<IconVersions class="h-3.5 w-3.5 text-foreground -ml-1 -mr-1" />
<span class="sr-only">Versions</span>
</FormButton>
<div
v-if="resourceItems.length"
v-tippy="removeEnabled ? undefined : 'You cannot remove the last model'"
>
<FormButton
size="sm"
color="subtle"
:disabled="!removeEnabled"
@click="handleRemove()"
>
<IconMinus class="h-3.5 w-3.5 text-foreground -ml-1 -mr-1" />
<span class="sr-only">
{{ showRemove ? 'Done' : 'Remove' }}
</span>
</FormButton>
</div>
<FormButton
size="sm"
color="subtle"
:disabled="showRemove"
@click="open = true"
>
<IconPlus class="h-3.5 w-3.5 text-foreground -ml-1 -mr-1" />
<span class="sr-only">Add model</span>
</FormButton>
</div>
</div>
</template>
<div class="flex flex-col h-full">
<template v-if="resourceItems.length">
<!-- Models with Scene Explorer -->
<template v-if="!showRaw">
<div
v-for="({ model, versionId }, index) in modelsAndVersionIds"
:key="model.id"
>
<ViewerModelsCard
:model="model"
:version-id="versionId"
:last="index === modelsAndVersionIds.length - 1"
:show-remove="showRemove"
:expand-level="expandLevel"
:manual-expand-level="manualExpandLevel"
:root-nodes="getRootNodesForModel(model.id)"
@remove="(id: string) => removeModel(id)"
@expanded="(e: number) => (manualExpandLevel < e ? (manualExpandLevel = e) : '')"
/>
</div>
<template v-if="objects.length !== 0">
<ViewerResourcesObjectCard
v-for="object in objects"
:key="object.objectId"
:object="object"
:show-remove="showRemove"
@remove="(id: string) => removeModel(id)"
/>
</template>
</template>
<!-- Dev Mode -->
<ViewerDataviewerPanel v-if="showRaw" class="pointer-events-auto" />
</template>
<!-- Empty State -->
<div v-else class="flex flex-col items-center justify-center gap-4 h-full">
<IconViewerModels class="h-10 w-10 text-foreground-2" />
<p class="text-body-xs text-foreground-2">No models loaded, yet.</p>
<FormButton @click="open = true">Add model</FormButton>
</div>
</div>
<ViewerResourcesAddModelDialog v-model:open="open" />
</ViewerLayoutSidePanel>
</template>
<script setup lang="ts">
import {
useInjectedViewerLoadedResources,
useInjectedViewerRequestedResources,
useInjectedViewer,
useInjectedViewerState
} from '~~/lib/viewer/composables/setup'
import { SpeckleViewer } from '@speckle/shared'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { ViewerEvent } from '@speckle/viewer'
import { useViewerEventListener } from '~~/lib/viewer/composables/viewer'
import type { ExplorerNode } from '~~/lib/viewer/helpers/sceneExplorer'
import { sortBy, flatten } from 'lodash-es'
defineEmits(['close', 'openVersions'])
const showRemove = ref(false)
const { resourceItems, modelsAndVersionIds, objects } =
useInjectedViewerLoadedResources()
const { items } = useInjectedViewerRequestedResources()
const {
metadata: { worldTree }
} = useInjectedViewer()
const {
resources: {
response: { resourceItems: stateResourceItems }
}
} = useInjectedViewerState()
const open = ref(false)
const expandLevel = ref(-1)
const manualExpandLevel = ref(-1)
const showRaw = ref(false)
const mp = useMixpanel()
const removeEnabled = computed(() => items.value.length > 1)
const removeModel = async (modelId: string) => {
// Convert requested resource string to references to specific models
// to ensure remove works even when we have "all" or "$folder" in the URL
const builder = SpeckleViewer.ViewerRoute.resourceBuilder()
for (const loadedResource of resourceItems.value) {
if (loadedResource.modelId) {
if (loadedResource.modelId !== modelId) {
builder.addModel(loadedResource.modelId, loadedResource.versionId || undefined)
}
} else {
if (loadedResource.objectId !== modelId)
builder.addObject(loadedResource.objectId)
}
}
mp.track('Viewer Action', { type: 'action', name: 'federation', action: 'remove' })
await items.update(builder.toResources())
}
// TODO: worldTree being set in postSetup.ts (viewer) does not seem to create a reactive effect
// in here (as i was expecting it to?). Therefore, refHack++ to trigger the computed prop rootNodes.
// Possibly Fabs will know more :)
const refhack = ref(1)
useViewerEventListener(ViewerEvent.LoadComplete, () => {
refhack.value++
})
const getRootNodesForModel = (modelId: string) => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
refhack.value
if (!worldTree.value) return []
const rootNodes = worldTree.value._root.children as ExplorerNode[]
const results: Record<number, ExplorerNode[]> = {}
const unmatchedNodes: ExplorerNode[] = []
for (const node of rootNodes) {
const objectId = ((node.model as Record<string, unknown>).id as string)
.split('/')
.reverse()[0] as string
const resourceItemIdx = stateResourceItems.value.findIndex(
(res) => res.objectId === objectId
)
const resourceItem =
resourceItemIdx !== -1 ? stateResourceItems.value[resourceItemIdx] : null
const raw = node.model?.raw as Record<string, unknown>
if (resourceItem?.modelId) {
// Model resource
const model = modelsAndVersionIds.value.find(
(item) => item.model.id === resourceItem.modelId
)?.model
raw.name = model?.name
raw.type = model?.id
// Only include nodes for this specific model
if (resourceItem.modelId === modelId) {
const res = node.model as ExplorerNode
if (resourceItem) {
;(results[resourceItemIdx] = results[resourceItemIdx] || []).push(res)
} else {
unmatchedNodes.push(res)
}
}
} else {
raw.name = 'Object'
raw.type = 'Single object'
// For single objects, include if the objectId matches
if (resourceItem && resourceItem.objectId === modelId) {
const res = node.model as ExplorerNode
unmatchedNodes.push(res)
}
}
}
const nodes = [
...flatten(sortBy(Object.entries(results), (i) => i[0]).map((i) => i[1])),
...unmatchedNodes
]
return nodes
}
const handleRemove = () => {
showRemove.value = true
}
watch(modelsAndVersionIds, (newVal) => {
if (newVal.length <= 1) showRemove.value = false
})
</script>
@@ -0,0 +1,265 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<div class="relative border-b border-outline-3">
<div
@mouseenter="highlightObject"
@mouseleave="unhighlightObject"
@focusin="highlightObject"
@focusout="unhighlightObject"
>
<!-- Model Header -->
<div
class="group flex items-center px-1 py-3 select-none cursor-pointer hover:bg-highlight-1"
:class="isExpanded && !showRemove ? 'border-b border-outline-3' : ''"
>
<FormButton size="sm" color="subtle" @click.stop="isExpanded = !isExpanded">
<IconTriangle
class="w-4 h-4 -ml-1.5 -mr-1.5 text-foreground-2"
:class="isExpanded ? 'rotate-90' : ''"
/>
<span class="sr-only">
{{ isExpanded ? 'Collapse' : 'Expand' }}
</span>
</FormButton>
<div class="h-12 w-12 rounded-md overflow-hidden border border-outline-3 mr-3">
<NuxtImg
:src="loadedVersion?.previewUrl"
class="object-cover h-full w-full"
/>
</div>
<div class="flex flex-col">
<div
v-tippy="modelName.subheader ? model.name : null"
class="text-body-2xs font-medium"
>
{{ modelName.header }}
</div>
<div v-if="isLatest" class="text-body-3xs text-foreground">
Latest version
</div>
<div class="text-body-3xs text-foreground-2">
{{ createdAtFormatted.relative }}
</div>
</div>
<div class="flex items-center gap-2 ml-auto">
<div class="flex">
<FormButton
size="sm"
color="subtle"
class="group-hover:opacity-100"
:class="{
'opacity-100': isHidden,
'opacity-0': !isHidden
}"
@click="hideOrShowObject"
>
<EyeIcon v-if="!isHidden" class="h-3 w-3 text-foreground -ml-1 -mr-1" />
<EyeSlashIcon v-else class="h-3 w-3 text-foreground -ml-1 -mr-1" />
<span class="sr-only">
{{ isHidden ? 'Show' : 'Hide' }}
</span>
</FormButton>
<FormButton
size="sm"
color="subtle"
:class="{
'opacity-100': isIsolated,
'opacity-0': !isIsolated
}"
class="group-hover:opacity-100"
@click="isolateOrUnisolateObject"
>
<FunnelIconOutline
v-if="!isIsolated"
class="h-3 w-3 text-foreground -ml-1 -mr-1"
/>
<FunnelIcon v-else class="h-3 w-3 text-foreground -ml-1 -mr-1" />
<span class="sr-only">
{{ isIsolated ? 'Unisolate' : 'Isolate' }}
</span>
</FormButton>
</div>
</div>
</div>
<!-- Scene Explorer Content -->
<div
v-if="isExpanded && rootNodeChildren.length && !showRemove"
class="relative flex flex-col gap-y-2"
>
<div v-for="(childNode, idx) in rootNodeChildren" :key="idx" class="rounded-xl">
<ViewerExplorerTreeItem
:tree-item="{ rawNode: markRaw(childNode) }"
:sub-header="'Model content'"
:expand-level="expandLevel"
:manual-expand-level="manualExpandLevel"
@expanded="(e: number) => $emit('expanded', e)"
/>
</div>
</div>
</div>
<!-- Remove Overlay -->
<div
v-if="showRemove"
class="absolute top-0 right-2 h-full z-10 flex items-center justify-end"
>
<FormButton
color="danger"
size="sm"
hide-text
:icon-left="XMarkIcon"
@click="$emit('remove', props.model.id)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { XMarkIcon, EyeIcon, EyeSlashIcon, FunnelIcon } from '@heroicons/vue/24/solid'
import { FunnelIcon as FunnelIconOutline } from '@heroicons/vue/24/outline'
import type { ViewerLoadedResourcesQuery } from '~~/lib/common/generated/gql/graphql'
import type { Get } from 'type-fest'
import type { ExplorerNode } from '~~/lib/viewer/helpers/sceneExplorer'
import {
useHighlightedObjectsUtilities,
useFilterUtilities
} from '~~/lib/viewer/composables/ui'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { containsAll } from '~~/lib/common/helpers/utils'
type ModelItem = NonNullable<Get<ViewerLoadedResourcesQuery, 'project.models.items[0]'>>
defineEmits<{
(e: 'remove', val: string): void
(e: 'expanded', depth: number): void
}>()
const props = defineProps<{
model: ModelItem
versionId: string
showRemove: boolean
last: boolean
expandLevel: number
manualExpandLevel: number
rootNodes: ExplorerNode[]
}>()
const { highlightObjects, unhighlightObjects } = useHighlightedObjectsUtilities()
const { hideObjects, showObjects, isolateObjects, unIsolateObjects } =
useFilterUtilities()
const {
viewer: {
metadata: { filteringState }
}
} = useInjectedViewerState()
const isExpanded = ref(false)
const rootNodeChildren = computed(() => {
const children: ExplorerNode[] = []
for (const rootNode of props.rootNodes) {
if (rootNode.children && rootNode.children.length > 0) {
children.push(...rootNode.children)
}
}
return children
})
const versions = computed(() => [
...props.model.loadedVersion.items,
...props.model.versions.items
])
const loadedVersion = computed(() =>
versions.value.find((v) => v.id === props.versionId)
)
const createdAt = computed(() => loadedVersion.value?.createdAt)
const createdAtFormatted = computed(() => {
return {
full: formattedFullDate(createdAt.value),
relative: formattedRelativeDate(createdAt.value, { capitalize: true })
}
})
const latestVersion = computed(() => {
return versions.value
.slice()
.sort((a, b) => (dayjs(a.createdAt).isBefore(dayjs(b.createdAt)) ? 1 : -1))[0]
})
const isLatest = computed(() => loadedVersion.value?.id === latestVersion.value.id)
const modelName = computed(() => {
const parts = props.model.name.split('/')
if (parts.length > 1) {
const name = parts[parts.length - 1]
parts.pop()
return {
subheader: parts.join('/'),
header: name
}
} else {
return {
subheader: null,
header: props.model.name
}
}
})
// Get target object IDs for the model
const modelObjectIds = computed(() => {
const refObject = props.model.loadedVersion.items[0]?.referencedObject
return refObject ? [refObject] : []
})
// State for hide/show and isolate
const hiddenObjects = computed(() => filteringState.value?.hiddenObjects)
const isolatedObjects = computed(() => filteringState.value?.isolatedObjects)
const isHidden = computed(() => {
if (!hiddenObjects.value || modelObjectIds.value.length === 0) return false
return containsAll(modelObjectIds.value, hiddenObjects.value)
})
const isIsolated = computed(() => {
if (!isolatedObjects.value || modelObjectIds.value.length === 0) return false
return containsAll(modelObjectIds.value, isolatedObjects.value)
})
// Functions for hide/show and isolate
const hideOrShowObject = (e: Event) => {
e.stopPropagation()
if (modelObjectIds.value.length === 0) return
if (!isHidden.value) {
hideObjects(modelObjectIds.value)
} else {
showObjects(modelObjectIds.value)
}
}
const isolateOrUnisolateObject = (e: Event) => {
e.stopPropagation()
if (modelObjectIds.value.length === 0) return
if (!isIsolated.value) {
isolateObjects(modelObjectIds.value)
} else {
unIsolateObjects(modelObjectIds.value)
}
}
const highlightObject = () => {
const refObject = props.model.loadedVersion.items[0]?.referencedObject
if (refObject) highlightObjects([refObject])
}
const unhighlightObject = () => {
const refObject = props.model.loadedVersion.items[0]?.referencedObject
if (refObject) unhighlightObjects([refObject])
}
</script>
@@ -0,0 +1,93 @@
<template>
<ViewerLayoutSidePanel>
<template #title>
<FormButton
size="sm"
color="subtle"
:icon-left="ArrowLeftIcon"
class="-ml-2"
@click="$emit('close')"
>
Exit versions
</FormButton>
</template>
<div class="flex flex-col">
<template v-if="resourceItems.length">
<div
v-for="({ model, versionId }, index) in modelsAndVersionIds"
:key="model.id"
>
<ViewerVersionsCard
:model="model"
:version-id="versionId"
:last="index === modelsAndVersionIds.length - 1"
:show-remove="showRemove"
@remove="(id: string) => removeModel(id)"
/>
</div>
<template v-if="objects.length !== 0">
<ViewerResourcesObjectCard
v-for="object in objects"
:key="object.objectId"
:object="object"
:show-remove="showRemove"
@remove="(id: string) => removeModel(id)"
/>
</template>
</template>
<!-- Empty State -->
<div v-else class="flex flex-col items-center justify-center gap-4 h-full">
<IconVersions class="h-10 w-10 text-foreground-2" />
<p class="text-body-xs text-foreground-2">No models loaded, yet.</p>
<FormButton @click="open = true">Add model</FormButton>
</div>
</div>
<ViewerResourcesAddModelDialog v-model:open="open" />
</ViewerLayoutSidePanel>
</template>
<script setup lang="ts">
import {
useInjectedViewerLoadedResources,
useInjectedViewerRequestedResources
} from '~~/lib/viewer/composables/setup'
import { ArrowLeftIcon } from '@heroicons/vue/24/solid'
import { SpeckleViewer } from '@speckle/shared'
import { useMixpanel } from '~~/lib/core/composables/mp'
defineEmits(['close'])
const showRemove = ref(false)
const { resourceItems, modelsAndVersionIds, objects } =
useInjectedViewerLoadedResources()
const { items } = useInjectedViewerRequestedResources()
const open = ref(false)
const mp = useMixpanel()
const removeModel = async (modelId: string) => {
// Convert requested resource string to references to specific models
// to ensure remove works even when we have "all" or "$folder" in the URL
const builder = SpeckleViewer.ViewerRoute.resourceBuilder()
for (const loadedResource of resourceItems.value) {
if (loadedResource.modelId) {
if (loadedResource.modelId !== modelId) {
builder.addModel(loadedResource.modelId, loadedResource.versionId || undefined)
}
} else {
if (loadedResource.objectId !== modelId)
builder.addObject(loadedResource.objectId)
}
}
mp.track('Viewer Action', { type: 'action', name: 'federation', action: 'remove' })
await items.update(builder.toResources())
}
watch(modelsAndVersionIds, (newVal) => {
if (newVal.length <= 1) showRemove.value = false
})
</script>
@@ -0,0 +1,232 @@
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<div class="relative border-b border-outline-3">
<div
:class="showVersions ? 'max-h-96 shadow-md' : ''"
@mouseenter="highlightObject"
@mouseleave="unhighlightObject"
@focusin="highlightObject"
@focusout="unhighlightObject"
>
<!-- Model Header -->
<div
class="group sticky cursor-pointer top-0 z-20 flex min-w-0 max-w-full items-center justify-between gap-3 pl-1 pr-4 py-2 select-none"
:class="showVersions ? 'bg-primary' : 'bg-foundation hover:bg-foundation-2'"
@click="showVersions = !showVersions"
>
<div class="h-12 w-12 rounded-md overflow-hidden border border-outline-3">
<NuxtImg
:src="loadedVersion?.previewUrl"
class="object-cover h-full w-full"
/>
</div>
<div class="flex min-w-0 flex-grow flex-col">
<div
v-tippy="modelName.subheader ? model.name : null"
:class="`${
showVersions ? 'text-foundation' : ''
} text-body-xs truncate min-w-0`"
>
{{ modelName.header }}
</div>
<div class="truncate -mt-1.5">
<span
v-tippy="createdAtFormatted.full"
:class="`${
showVersions ? 'text-foundation' : 'text-foreground-2'
} text-body-3xs`"
>
{{ isLatest ? 'Latest version' : createdAtFormatted.relative }}
</span>
</div>
</div>
<span v-if="!showVersions" class="text-foreground-2 text-body-2xs font-medium">
{{ model.versions?.totalCount }}
</span>
<div
v-else
:class="`${
showVersions ? 'text-white' : ''
} flex flex-none items-center space-x-2 text-xs font-medium opacity-80 transition-opacity group-hover:opacity-100`"
>
<ChevronUpIcon class="h-4 w-4" />
</div>
</div>
<!-- Active Version Card (when expanded but no scene data) -->
<ViewerResourcesActiveVersionCard
v-if="loadedVersion && showVersions"
:version="loadedVersion"
/>
</div>
<!-- Remove Overlay -->
<Transition>
<div
v-if="showRemove"
class="to-foundation group absolute inset-0 z-[21] flex h-full w-full items-center justify-end space-x-2 rounded bg-gradient-to-r from-blue-500/0 p-4"
>
<FormButton
color="danger"
size="sm"
hide-text
:icon-left="XMarkIcon"
@click="$emit('remove', props.model.id)"
/>
</div>
</Transition>
<!-- Version List (when expanded and not in remove mode) -->
<div
v-show="showVersions && !showRemove"
class="mt-2 ml-4 flex h-auto flex-col space-y-0"
>
<ViewerResourcesVersionCard
v-for="(version, index) in props.model.versions.items"
:key="version.id"
:model-id="modelId"
:version="version"
:is-latest-version="version.id === latestVersionId"
:is-loaded-version="version.id === loadedVersion?.id"
:last="index === props.model.versions.totalCount - 1"
:last-loaded="index === props.model.versions.items.length - 1"
:clickable="version.id !== loadedVersion?.id"
@change-version="handleVersionChange"
@view-changes="handleViewChanges"
/>
<div class="mt-4 px-2 py-2">
<FormButton full-width text :disabled="!showLoadMore" @click="onLoadMore">
{{ showLoadMore ? 'View older versions' : 'No more versions' }}
</FormButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { graphql } from '~~/lib/common/generated/gql'
import { XMarkIcon, ChevronUpIcon } from '@heroicons/vue/24/solid'
import type {
ViewerLoadedResourcesQuery,
ViewerModelVersionCardItemFragment
} from '~~/lib/common/generated/gql/graphql'
import type { Get } from 'type-fest'
import {
useInjectedViewerLoadedResources,
useInjectedViewerRequestedResources
} from '~~/lib/viewer/composables/setup'
import {
useDiffUtilities,
useHighlightedObjectsUtilities
} from '~~/lib/viewer/composables/ui'
type ModelItem = NonNullable<Get<ViewerLoadedResourcesQuery, 'project.models.items[0]'>>
defineEmits<{
(e: 'remove', val: string): void
}>()
const props = defineProps<{
model: ModelItem
versionId: string
showRemove: boolean
last: boolean
}>()
const { switchModelToVersion } = useInjectedViewerRequestedResources()
const { loadMoreVersions } = useInjectedViewerLoadedResources()
const { diffModelVersions } = useDiffUtilities()
const { highlightObjects, unhighlightObjects } = useHighlightedObjectsUtilities()
const showVersions = ref(false)
graphql(`
fragment ViewerModelVersionCardItem on Version {
id
message
referencedObject
sourceApplication
createdAt
previewUrl
authorUser {
...LimitedUserAvatar
}
}
`)
const modelId = computed(() => props.model.id)
const versions = computed(() => [
...props.model.loadedVersion.items,
...props.model.versions.items
])
const showLoadMore = computed(() => {
const totalCount = props.model.versions.totalCount
const currentCount = versions.value.length
return currentCount < totalCount
})
const loadedVersion = computed(() =>
versions.value.find((v) => v.id === props.versionId)
)
const createdAt = computed(() => loadedVersion.value?.createdAt)
const createdAtFormatted = computed(() => {
return {
full: formattedFullDate(createdAt.value),
relative: formattedRelativeDate(createdAt.value, { capitalize: true })
}
})
const latestVersion = computed(() => {
return versions.value
.slice()
.sort((a, b) => (dayjs(a.createdAt).isBefore(dayjs(b.createdAt)) ? 1 : -1))[0]
})
const isLatest = computed(() => loadedVersion.value?.id === latestVersion.value.id)
const latestVersionId = computed(() => latestVersion.value.id)
const modelName = computed(() => {
const parts = props.model.name.split('/')
if (parts.length > 1) {
const name = parts[parts.length - 1]
parts.pop()
return {
subheader: parts.join('/'),
header: name
}
} else {
return {
subheader: null,
header: props.model.name
}
}
})
async function handleVersionChange(versionId: string) {
await switchModelToVersion(props.model.id, versionId)
}
const onLoadMore = async () => {
await loadMoreVersions(props.model.id)
}
async function handleViewChanges(version: ViewerModelVersionCardItemFragment) {
if (!loadedVersion.value?.id) return
await diffModelVersions(modelId.value, loadedVersion.value.id, version.id)
}
const highlightObject = () => {
const refObject = props.model.loadedVersion.items[0]?.referencedObject
if (refObject) highlightObjects([refObject])
}
const unhighlightObject = () => {
const refObject = props.model.loadedVersion.items[0]?.referencedObject
if (refObject) unhighlightObjects([refObject])
}
</script>
@@ -17,24 +17,9 @@
<IconViewerModels class="h-4 w-4 md:h-5 md:w-5" />
</ViewerControlsButtonToggle>
<!-- Explorer -->
<ViewerControlsButtonToggle
v-tippy="{
content: getShortcutDisplayText(shortcuts.ToggleExplorer),
placement: 'right'
}"
:active="activePanel === 'explorer'"
@click="toggleActivePanel('explorer')"
>
<IconViewerExplorer class="h-4 w-4 md:h-5 md:w-5" />
</ViewerControlsButtonToggle>
<!-- Filters -->
<ViewerControlsButtonToggle
v-tippy="{
content: 'Filters',
placement: 'right'
}"
v-tippy="getShortcutDisplayText(shortcuts.ToggleFilters)"
:active="activePanel === 'filters'"
@click="toggleActivePanel('filters')"
>
@@ -93,24 +78,25 @@
>
<!-- Models panel -->
<KeepAlive v-show="activePanel === 'models'">
<ViewerResourcesList
<ViewerModels
v-if="!enabled"
class="pointer-events-auto"
@close="activePanel = 'none'"
@open-versions="activePanel = 'versions'"
/>
<ViewerCompareChangesPanel v-else @close="activePanel = 'none'" />
</KeepAlive>
<!-- Explorer panel -->
<KeepAlive v-show="resourceItems.length !== 0 && activePanel === 'explorer'">
<ViewerExplorer class="pointer-events-auto" @close="activePanel = 'none'" />
</KeepAlive>
<!-- Filter panel -->
<KeepAlive v-show="resourceItems.length !== 0 && activePanel === 'filters'">
<ViewerFilters class="pointer-events-auto" @close="activePanel = 'none'" />
</KeepAlive>
<!-- Versions panel -->
<KeepAlive v-show="resourceItems.length !== 0 && activePanel === 'versions'">
<ViewerVersions class="pointer-events-auto" @close="activePanel = 'models'" />
</KeepAlive>
<!-- Comment threads panel -->
<ViewerComments
v-if="resourceItems.length !== 0 && activePanel === 'discussions'"
@@ -136,9 +122,9 @@ type ActivePanel =
| 'none'
| 'models'
| 'discussions'
| 'explorer'
| 'automate'
| 'filters'
| 'versions'
const width = ref(264)
const scrollableControlsContainer = ref(null as Nullable<HTMLDivElement>)
@@ -210,7 +196,7 @@ const { summary } = useFunctionRunsStatusSummary({
registerShortcuts({
ToggleModels: () => toggleActivePanel('models'),
ToggleExplorer: () => toggleActivePanel('explorer'),
ToggleFilters: () => toggleActivePanel('filters'),
ToggleDiscussions: () => toggleActivePanel('discussions')
})
@@ -1,97 +1,86 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
<template>
<div class="w-full select-none">
<div>
<!-- Header -->
<div class="bg-foundation w-full rounded-md py-1 px-1">
<div class="flex w-full items-stretch space-x-1">
<!-- Unfold button -->
<div class="flex w-5 flex-shrink-0 justify-center overflow-hidden">
<button
v-if="isSingleCollection || isMultipleCollection"
class="hover:bg-foundation-2 hover:text-primary flex h-full w-full items-center justify-center rounded"
@click="manualUnfoldToggle()"
>
<ChevronDownIcon
:class="`h-3 w-3 ${!unfold ? '-rotate-90' : 'rotate-0'} ${
isSelected ? 'text-primary' : ''
}`"
/>
</button>
</div>
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events -->
<div
:class="`hover:bg-foundation-2 group flex flex-grow cursor-pointer items-center space-x-1 overflow-hidden rounded border-l-4 pl-2 pr-1
${isSelected ? 'border-primary bg-foundation-2' : 'border-transparent'}
`"
@click="(e:MouseEvent) => setSelection(e)"
@mouseenter="highlightObject"
@focusin="highlightObject"
@mouseleave="unhighlightObject"
@focusout="unhighlightObject"
<div
class="group flex items-center justify-between w-full p-2 pr-1 cursor-pointer"
:class="[
unfold && !isSelected ? 'bg-foundation-2' : '',
isSelected ? 'bg-highlight-3' : 'hover:bg-highlight-1'
]"
@click="(e:MouseEvent) => setSelection(e)"
@mouseenter="highlightObject"
@focusin="highlightObject"
@mouseleave="unhighlightObject"
@focusout="unhighlightObject"
>
<div class="flex items-center gap-0.5">
<FormButton
v-if="isSingleCollection || isMultipleCollection"
size="sm"
color="subtle"
@click.stop="manualUnfoldToggle"
>
<div
:class="`truncate ${
isHidden || (!isIsolated && stateHasIsolatedObjectsInGeneral)
? 'text-foreground-2'
: ''
}`"
>
<div :class="`truncate text-body-xs ${unfold ? 'font-medium' : ''}`">
<!-- Note, enforce header from parent if provided (used in the case of root nodes) -->
{{ header || headerAndSubheader.header }}
</div>
<div class="text-body-3xs text-foreground-2 truncate -mt-0.5">
{{ subHeader || headerAndSubheader.subheader }}
</div>
<div v-if="debug" class="text-tiny text-foreground-2">
unfold: {{ unfold }} / selected: {{ isSelected }} / hidden:
{{ isHidden }} / isolated:
{{ isIsolated }}
</div>
<IconTriangle
class="w-4 h-4 -ml-1.5 -mr-1.5 text-foreground-2"
:class="unfold ? 'rotate-90' : ''"
/>
<span class="sr-only">
{{ unfold ? 'Collapse' : 'Expand' }}
</span>
</FormButton>
<div v-else class="w-4 shrink-0"></div>
<div
class="flex flex-col"
:class="
isHidden || (!isIsolated && stateHasIsolatedObjectsInGeneral)
? 'text-foreground-2'
: ''
"
>
<div class="flex truncate text-body-2xs">
<!-- Note, enforce header from parent if provided (used in the case of root nodes) -->
{{ header || headerAndSubheader.header }}
</div>
<div class="flex-grow"></div>
<div class="flex flex-shrink-0 items-center space-x-1">
<!-- <div v-if="!(isSingleCollection || isMultipleCollection)"> -->
<div class="flex space-x-2">
<button
:class="`hover:text-primary px-1 py-2 opacity-0 group-hover:opacity-100 ${
isHidden ? 'opacity-100' : ''
}`"
@click.stop="hideOrShowObject"
>
<EyeIcon v-if="!isHidden" class="h-3 w-3" />
<EyeSlashIcon v-else class="h-3 w-3" />
</button>
<button
:class="`hover:text-primary px-1 py-2 opacity-0 group-hover:opacity-100 ${
isIsolated ? 'opacity-100' : ''
}`"
@click.stop="isolateOrUnisolateObject"
>
<FunnelIconOutline v-if="!isIsolated" class="h-3 w-3" />
<FunnelIcon v-else class="h-3 w-3" />
</button>
</div>
<div
v-if="
(isSingleCollection || isMultipleCollection) &&
typeof childrenLength === 'number'
"
>
<span class="text-foreground-2 text-xs">({{ childrenLength }})</span>
</div>
<div class="flex text-body-3xs text-foreground-2 truncate">
{{ subHeader || headerAndSubheader.subheader }}
</div>
</div>
</div>
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events -->
<div v-if="debug" class="text-foreground-2 text-xs" @click="setSelection">
single: {{ isSingleCollection }}; multiple: {{ isMultipleCollection }}; a:
{{ isAtomic }}
<div class="flex items-center">
<FormButton
size="sm"
color="subtle"
hide-text
:icon-left="isHidden ? EyeSlashIcon : EyeIcon"
:class="
isHidden || isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
"
@click.stop="hideOrShowObject"
>
{{ isHidden ? 'Show' : 'Hide' }}
</FormButton>
<FormButton
size="sm"
color="subtle"
hide-text
:icon-left="isIsolated ? FunnelIcon : FunnelIconOutline"
:class="
isIsolated || isSelected
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
"
@click.stop="isolateOrUnisolateObject"
>
{{ isIsolated ? 'Unisolate' : 'Isolate' }}
</FormButton>
</div>
</div>
<!-- Children Contents -->
<div v-if="unfold" class="relative pl-1 text-xs">
<!-- Children contents -->
<div v-if="unfold" class="pl-1.5 bg-foundation-2">
<!-- If we have array collections -->
<div v-if="isMultipleCollection">
<!-- mul col items -->
@@ -105,7 +94,6 @@
:depth="depth + 1"
:expand-level="props.expandLevel"
:manual-expand-level="manualExpandLevel"
:debug="debug"
:parent="treeItem"
@expanded="(e) => $emit('expanded', e)"
/>
@@ -124,7 +112,6 @@
:depth="depth + 1"
:expand-level="props.expandLevel"
:manual-expand-level="manualExpandLevel"
:debug="debug"
:parent="treeItem"
@expanded="(e) => $emit('expanded', e)"
/>
@@ -139,12 +126,7 @@
</div>
</template>
<script setup lang="ts">
import {
ChevronDownIcon,
EyeIcon,
EyeSlashIcon,
FunnelIcon
} from '@heroicons/vue/24/solid'
import { EyeIcon, EyeSlashIcon, FunnelIcon } from '@heroicons/vue/24/solid'
import { FunnelIcon as FunnelIconOutline } from '@heroicons/vue/24/outline'
import type {
ExplorerNode,
@@ -169,13 +151,12 @@ const props = withDefaults(
treeItem: TreeItemComponentModel
parent?: TreeItemComponentModel
depth?: number
debug?: boolean
expandLevel: number
manualExpandLevel: number
header?: string | null
subHeader?: string | null
}>(),
{ depth: 0, debug: false, header: null, subHeader: null }
{ depth: 0, header: null, subHeader: null }
)
const emit = defineEmits<{
@@ -212,14 +193,6 @@ const headerAndSubheader = computed(() => {
}
})
const childrenLength = computed(() => {
if (rawSpeckleData.value.elements && Array.isArray(rawSpeckleData.value.elements))
return rawSpeckleData.value.elements.length
if (rawSpeckleData.value.children && Array.isArray(rawSpeckleData.value.children))
return rawSpeckleData.value.children.length
return 0
})
const isSingleCollection = computed(() => {
return (
isNonEmptyObjectArray(speckleData.value.children) ||
@@ -1,59 +1,24 @@
<template>
<div
class="bg-foundation overflow-hidden border-outline-2 flex flex-col h-full"
:class="borderPosition === 'left' ? 'border-l' : 'border-r'"
>
<div class="sticky top-0 z-50 flex flex-col bg-foundation">
<div v-if="!hideClose" class="absolute top-1.5 right-1 z-10">
<FormButton
color="subtle"
size="sm"
hide-text
:icon-left="XMarkIcon"
@click="$emit('close')"
/>
<div class="bg-foundation overflow-hidden border-outline-2 flex flex-col h-full">
<div class="flex justify-between items-center border-b border-outline-3 p-2 pl-4">
<div class="text-body-xs text-foreground font-medium">
<span v-if="title" class="truncate">{{ title }}</span>
<slot name="title"></slot>
</div>
<div
v-if="$slots.title"
class="flex items-center py-1.5 px-3 border-b border-outline-2"
>
<div
class="flex items-center h-full w-full pr-8 text-body-xs text-foreground font-medium"
>
<span class="truncate">
<slot name="title"></slot>
</span>
</div>
<div>
<slot name="actions"></slot>
</div>
</div>
<div
v-if="$slots.actions"
class="flex items-center py-1.5 px-3 relative z-10 border-outline-2"
:class="moveActionsToBottom ? 'order-3 border-t' : 'order-2 border-b'"
>
<slot name="actions"></slot>
</div>
<div :class="moveActionsToBottom ? 'order-2' : 'order-3'">
<div class="flex-1">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { XMarkIcon } from '@heroicons/vue/24/solid'
defineEmits(['close'])
withDefaults(
defineProps<{
hideClose?: boolean
moveActionsToBottom?: boolean
borderPosition?: 'left' | 'right'
}>(),
{
hideClose: false,
moveActionsToBottom: false,
borderPosition: 'right'
}
)
defineProps<{
title?: string
}>()
</script>
@@ -1,20 +1,22 @@
<template>
<ViewerLayoutSidePanel @close="$emit('close')">
<template #title>Models</template>
<ViewerLayoutSidePanel title="Models">
<template #actions>
<div class="flex gap-x-1.5">
<FormButton
size="sm"
color="outline"
color="subtle"
:icon-left="PlusIcon"
hide-text
:disabled="showRemove"
@click="open = true"
>
Add
Add model
</FormButton>
<FormButton
v-if="resourceItems.length"
size="sm"
color="outline"
color="subtle"
hide-text
:icon-left="showRemove ? CheckIcon : MinusIcon"
:disabled="!removeEnabled"
@click="showRemove = !showRemove"
@@ -23,7 +25,7 @@
</FormButton>
</div>
</template>
<div class="flex flex-col space-y-2 px-1 py-1">
<div class="flex flex-col space-y-2 px-1 py-1 h-full">
<template v-if="resourceItems.length">
<div
v-for="({ model, versionId }, index) in modelsAndVersionIds"
@@ -47,6 +49,11 @@
/>
</template>
</template>
<div v-else class="flex flex-col items-center justify-center gap-4 h-full">
<IconViewerModels class="h-10 w-10 text-foreground-2" />
<p class="text-body-xs text-foreground-2">No models loaded, yet.</p>
<FormButton @click="open = true">Add model</FormButton>
</div>
</div>
<ViewerResourcesAddModelDialog v-model:open="open" />
</ViewerLayoutSidePanel>
@@ -155,13 +155,13 @@ type Documents = {
"\n fragment SettingsWorkspacesSecurityWorkspaceCreation_Workspace on Workspace {\n id\n slug\n role\n isExclusive\n hasAccessToExclusiveMembership: hasAccessToFeature(featureName: exclusiveMembership)\n permissions {\n canMakeWorkspaceExclusive {\n authorized\n message\n }\n }\n }\n": typeof types.SettingsWorkspacesSecurityWorkspaceCreation_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecuritySsoWrapper_Workspace on Workspace {\n id\n role\n slug\n sso {\n provider {\n id\n name\n clientId\n issuerUrl\n }\n }\n hasAccessToSSO: hasAccessToFeature(featureName: oidcSso)\n }\n": typeof types.SettingsWorkspacesSecuritySsoWrapper_WorkspaceFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n ...ViewerLimitsDialog_Project\n }\n": typeof types.ModelPageProjectFragmentDoc,
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": typeof types.ViewerModelVersionCardItemFragmentDoc,
"\n fragment ViewerCommentThreadData on Comment {\n id\n permissions {\n canArchive {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ViewerCommentThreadDataFragmentDoc,
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": typeof types.ThreadCommentAttachmentFragmentDoc,
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": typeof types.ViewerCommentsListItemFragmentDoc,
"\n fragment ViewerLimitsDialog_Project on Project {\n id\n workspaceId\n ...ViewerLimitsWorkspaceDialog_Project\n ...WorkspaceMoveProject_Project\n }\n": typeof types.ViewerLimitsDialog_ProjectFragmentDoc,
"\n fragment ViewerLimitsWorkspaceDialog_Project on Project {\n id\n workspace {\n id\n role\n slug\n ...WorkspacePlanLimits_Workspace\n }\n ...UseLoadLatestVersion_Project\n }\n": typeof types.ViewerLimitsWorkspaceDialog_ProjectFragmentDoc,
"\n fragment ViewerResourcesLimitAlert_Project on Project {\n id\n workspaceId\n workspace {\n id\n slug\n ...ViewerResourcesWorkspaceLimitAlert_Workspace\n }\n ...WorkspaceMoveProject_Project\n }\n": typeof types.ViewerResourcesLimitAlert_ProjectFragmentDoc,
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": typeof types.ViewerModelVersionCardItemFragmentDoc,
"\n fragment ViewerResourcesPersonalLimitAlert_Project on Project {\n id\n ...WorkspaceMoveProject_Project\n }\n": typeof types.ViewerResourcesPersonalLimitAlert_ProjectFragmentDoc,
"\n fragment ViewerResourcesWorkspaceLimitAlert_Workspace on Workspace {\n id\n slug\n }\n": typeof types.ViewerResourcesWorkspaceLimitAlert_WorkspaceFragmentDoc,
"\n fragment WorkspaceAddProjectMenu_Workspace on Workspace {\n id\n name\n slug\n role\n plan {\n name\n }\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n canMoveProjectToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectsAdd_Workspace\n ...WorkspaceMoveProject_Workspace\n ...UseCanCreateWorkspaceProject_Workspace\n ...UseCanMoveProjectIntoWorkspace_Workspace\n }\n": typeof types.WorkspaceAddProjectMenu_WorkspaceFragmentDoc,
@@ -624,13 +624,13 @@ const documents: Documents = {
"\n fragment SettingsWorkspacesSecurityWorkspaceCreation_Workspace on Workspace {\n id\n slug\n role\n isExclusive\n hasAccessToExclusiveMembership: hasAccessToFeature(featureName: exclusiveMembership)\n permissions {\n canMakeWorkspaceExclusive {\n authorized\n message\n }\n }\n }\n": types.SettingsWorkspacesSecurityWorkspaceCreation_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecuritySsoWrapper_Workspace on Workspace {\n id\n role\n slug\n sso {\n provider {\n id\n name\n clientId\n issuerUrl\n }\n }\n hasAccessToSSO: hasAccessToFeature(featureName: oidcSso)\n }\n": types.SettingsWorkspacesSecuritySsoWrapper_WorkspaceFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n ...ViewerLimitsDialog_Project\n }\n": types.ModelPageProjectFragmentDoc,
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": types.ViewerModelVersionCardItemFragmentDoc,
"\n fragment ViewerCommentThreadData on Comment {\n id\n permissions {\n canArchive {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ViewerCommentThreadDataFragmentDoc,
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": types.ThreadCommentAttachmentFragmentDoc,
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": types.ViewerCommentsListItemFragmentDoc,
"\n fragment ViewerLimitsDialog_Project on Project {\n id\n workspaceId\n ...ViewerLimitsWorkspaceDialog_Project\n ...WorkspaceMoveProject_Project\n }\n": types.ViewerLimitsDialog_ProjectFragmentDoc,
"\n fragment ViewerLimitsWorkspaceDialog_Project on Project {\n id\n workspace {\n id\n role\n slug\n ...WorkspacePlanLimits_Workspace\n }\n ...UseLoadLatestVersion_Project\n }\n": types.ViewerLimitsWorkspaceDialog_ProjectFragmentDoc,
"\n fragment ViewerResourcesLimitAlert_Project on Project {\n id\n workspaceId\n workspace {\n id\n slug\n ...ViewerResourcesWorkspaceLimitAlert_Workspace\n }\n ...WorkspaceMoveProject_Project\n }\n": types.ViewerResourcesLimitAlert_ProjectFragmentDoc,
"\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n": types.ViewerModelVersionCardItemFragmentDoc,
"\n fragment ViewerResourcesPersonalLimitAlert_Project on Project {\n id\n ...WorkspaceMoveProject_Project\n }\n": types.ViewerResourcesPersonalLimitAlert_ProjectFragmentDoc,
"\n fragment ViewerResourcesWorkspaceLimitAlert_Workspace on Workspace {\n id\n slug\n }\n": types.ViewerResourcesWorkspaceLimitAlert_WorkspaceFragmentDoc,
"\n fragment WorkspaceAddProjectMenu_Workspace on Workspace {\n id\n name\n slug\n role\n plan {\n name\n }\n permissions {\n canCreateProject {\n ...FullPermissionCheckResult\n }\n canMoveProjectToWorkspace {\n ...FullPermissionCheckResult\n }\n }\n ...ProjectsAdd_Workspace\n ...WorkspaceMoveProject_Workspace\n ...UseCanCreateWorkspaceProject_Workspace\n ...UseCanMoveProjectIntoWorkspace_Workspace\n }\n": types.WorkspaceAddProjectMenu_WorkspaceFragmentDoc,
@@ -1530,6 +1530,10 @@ export function graphql(source: "\n fragment SettingsWorkspacesSecuritySsoWrapp
* 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 ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n ...ViewerLimitsDialog_Project\n }\n"): (typeof documents)["\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n workspace {\n id\n slug\n name\n role\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n ...ViewerLimitsDialog_Project\n }\n"];
/**
* 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 ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n"): (typeof documents)["\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1554,10 +1558,6 @@ export function graphql(source: "\n fragment ViewerLimitsWorkspaceDialog_Projec
* 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 ViewerResourcesLimitAlert_Project on Project {\n id\n workspaceId\n workspace {\n id\n slug\n ...ViewerResourcesWorkspaceLimitAlert_Workspace\n }\n ...WorkspaceMoveProject_Project\n }\n"): (typeof documents)["\n fragment ViewerResourcesLimitAlert_Project on Project {\n id\n workspaceId\n workspace {\n id\n slug\n ...ViewerResourcesWorkspaceLimitAlert_Workspace\n }\n ...WorkspaceMoveProject_Project\n }\n"];
/**
* 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 ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n"): (typeof documents)["\n fragment ViewerModelVersionCardItem on Version {\n id\n message\n referencedObject\n sourceApplication\n createdAt\n previewUrl\n authorUser {\n ...LimitedUserAvatar\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -5694,6 +5694,8 @@ export type SettingsWorkspacesSecuritySsoWrapper_WorkspaceFragment = { __typenam
export type ModelPageProjectFragment = { __typename?: 'Project', id: string, createdAt: string, name: string, visibility: ProjectVisibility, hasAccessToFeature: boolean, workspaceId?: string | null, workspace?: { __typename?: 'Workspace', id: string, slug: string, name: string, role?: string | null, plan?: { __typename?: 'WorkspacePlan', name: WorkspacePlans } | null } | null, embedOptions: { __typename?: 'ProjectEmbedOptions', hideSpeckleBranding: boolean }, permissions: { __typename?: 'ProjectPermissionChecks', canMoveToWorkspace: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: {} | null } } };
export type ViewerModelVersionCardItemFragment = { __typename?: 'Version', id: string, message?: string | null, referencedObject?: string | null, sourceApplication?: string | null, createdAt: string, previewUrl: string, authorUser?: { __typename?: 'LimitedUser', id: string, name: string, avatar?: string | null } | null };
export type ViewerCommentThreadDataFragment = { __typename?: 'Comment', id: string, permissions: { __typename?: 'CommentPermissionChecks', canArchive: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: {} | null } } };
export type ThreadCommentAttachmentFragment = { __typename?: 'Comment', text?: { __typename?: 'SmartTextEditorValue', attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, fileType: string, fileSize?: number | null }> | null } | null };
@@ -5706,8 +5708,6 @@ export type ViewerLimitsWorkspaceDialog_ProjectFragment = { __typename?: 'Projec
export type ViewerResourcesLimitAlert_ProjectFragment = { __typename?: 'Project', id: string, workspaceId?: string | null, workspace?: { __typename?: 'Workspace', id: string, slug: string } | null, permissions: { __typename?: 'ProjectPermissionChecks', canMoveToWorkspace: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: {} | null } } };
export type ViewerModelVersionCardItemFragment = { __typename?: 'Version', id: string, message?: string | null, referencedObject?: string | null, sourceApplication?: string | null, createdAt: string, previewUrl: string, authorUser?: { __typename?: 'LimitedUser', id: string, name: string, avatar?: string | null } | null };
export type ViewerResourcesPersonalLimitAlert_ProjectFragment = { __typename?: 'Project', id: string, workspaceId?: string | null, permissions: { __typename?: 'ProjectPermissionChecks', canMoveToWorkspace: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: {} | null } } };
export type ViewerResourcesWorkspaceLimitAlert_WorkspaceFragment = { __typename?: 'Workspace', id: string, slug: string };
@@ -9,12 +9,12 @@ export const PanelShortcuts = {
key: 'M',
action: 'ToggleModels'
},
ToggleExplorer: {
name: 'Scene explorer',
description: 'Toggle scene explorer panel',
ToggleFilters: {
name: 'Filters',
description: 'Toggle filters panel',
modifiers: [ModifierKeys.Shift],
key: 'E',
action: 'ToggleExplorer'
key: 'F',
action: 'ToggleFilters'
},
ToggleDiscussions: {
name: 'Discussions',