563 lines
17 KiB
Vue
563 lines
17 KiB
Vue
<template>
|
|
<!--
|
|
HIC SVNT DRACONES
|
|
-->
|
|
<div
|
|
ref="parent"
|
|
style="
|
|
width: 100%;
|
|
height: 100%;
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
overflow: hidden;
|
|
z-index: 25;
|
|
"
|
|
class="comment-add-overlay no-mouse"
|
|
>
|
|
<v-slide-x-transition>
|
|
<div
|
|
v-show="visible && !viewerState.selectedCommentMetaData"
|
|
ref="commentButton"
|
|
class="new-comment-overlay absolute-pos"
|
|
>
|
|
<div class="d-flex">
|
|
<v-btn
|
|
v-tooltip="!expand ? 'Add a comment (ctrl + shift + c)' : 'Cancel'"
|
|
small
|
|
icon
|
|
:dark="!expand"
|
|
:class="`mouse elevation-5 ${!expand ? 'primary' : 'background'} mr-2`"
|
|
:loading="loading"
|
|
@click="toggleExpand()"
|
|
>
|
|
<v-icon v-if="!expand" dark small>mdi-message</v-icon>
|
|
<v-icon v-else dark x-small>mdi-close</v-icon>
|
|
</v-btn>
|
|
<v-slide-x-transition>
|
|
<div
|
|
v-if="expand && !$vuetify.breakpoint.xs"
|
|
style="width: 100%; top: -10px; position: relative"
|
|
class=""
|
|
>
|
|
<div v-if="$loggedIn() && canComment" class="d-flex mouse">
|
|
<comment-editor
|
|
ref="desktopEditor"
|
|
v-model="commentValue"
|
|
:stream-id="streamId"
|
|
adding-comment
|
|
style="width: 300px"
|
|
max-height="300px"
|
|
:disabled="isSubmitDisabled"
|
|
@attachments-processing="anyAttachmentsProcessing = $event"
|
|
@submit="addComment()"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-if="$loggedIn() && canComment"
|
|
class="d-flex mt-2 mouse justify-end"
|
|
>
|
|
<v-fade-transition group>
|
|
<template v-if="isCommentEmpty">
|
|
<template v-for="reaction in viewerState.commentReactions">
|
|
<v-btn
|
|
:key="reaction"
|
|
class="mr-2"
|
|
fab
|
|
small
|
|
@click="addCommentDirect(reaction)"
|
|
>
|
|
<span
|
|
class="text-h5"
|
|
style="position: relative; top: 1px; left: -1px"
|
|
>
|
|
{{ reaction }}
|
|
</span>
|
|
</v-btn>
|
|
</template>
|
|
</template>
|
|
</v-fade-transition>
|
|
<v-btn
|
|
v-tooltip="'Add attachments'"
|
|
:disabled="loading"
|
|
fab
|
|
small
|
|
class="mx-2 elevation-10"
|
|
@click="addAttachments()"
|
|
>
|
|
<v-icon v-if="$vuetify.breakpoint.smAndDown" small>mdi-camera</v-icon>
|
|
<v-icon v-else small>mdi-paperclip</v-icon>
|
|
</v-btn>
|
|
<v-btn
|
|
v-tooltip="'Send comment (press enter)'"
|
|
:disabled="loading"
|
|
icon
|
|
dark
|
|
fab
|
|
small
|
|
class="primary mr-2 elevation-10"
|
|
@click="addComment()"
|
|
>
|
|
<v-icon dark small>mdi-send</v-icon>
|
|
</v-btn>
|
|
</div>
|
|
<div
|
|
v-if="!canComment && $loggedIn()"
|
|
class="caption background px-4 py-2 rounded-xl elevation-2"
|
|
>
|
|
You do not have sufficient permissions to add a comment to this stream.
|
|
</div>
|
|
<v-btn
|
|
v-if="!$loggedIn()"
|
|
block
|
|
depressed
|
|
color="primary"
|
|
class="rounded-xl mouse mt-2"
|
|
to="/authn/login"
|
|
>
|
|
<v-icon small class="mr-1">mdi-account</v-icon>
|
|
Sign in to comment
|
|
</v-btn>
|
|
</div>
|
|
</v-slide-x-transition>
|
|
</div>
|
|
|
|
<v-dialog
|
|
v-if="$vuetify.breakpoint.xs"
|
|
v-model="expand"
|
|
content-class="elevation-0 flat px-2"
|
|
@click:outside="toggleExpand()"
|
|
>
|
|
<div
|
|
v-if="!canComment && $loggedIn()"
|
|
class="caption background px-4 py-2 rounded-xl elevation-2"
|
|
>
|
|
You do not have sufficient permissions to add a comment to this stream.
|
|
</div>
|
|
<div
|
|
v-if="$loggedIn() && canComment"
|
|
class="d-flex justify-center"
|
|
style="position: relative"
|
|
>
|
|
<comment-editor
|
|
ref="mobileEditor"
|
|
v-model="commentValue"
|
|
:stream-id="streamId"
|
|
adding-comment
|
|
style="width: 100%"
|
|
max-height="60vh"
|
|
:disabled="isSubmitDisabled"
|
|
@submit="addComment()"
|
|
@attachments-processing="anyAttachmentsProcessing = $event"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-if="$loggedIn() && canComment"
|
|
class="my-2 d-flex justify-end"
|
|
style="position: relative"
|
|
>
|
|
<v-btn
|
|
v-if="!$loggedIn()"
|
|
block
|
|
depressed
|
|
color="primary"
|
|
class="rounded-xl"
|
|
to="/authn/login"
|
|
>
|
|
<v-icon small class="mr-1">mdi-account</v-icon>
|
|
Sign in to comment
|
|
</v-btn>
|
|
<v-fade-transition group>
|
|
<template v-if="isCommentEmpty">
|
|
<template v-for="reaction in viewerState.commentReactions">
|
|
<v-btn
|
|
:key="reaction"
|
|
class="mr-2 elevation-4"
|
|
fab
|
|
small
|
|
@click="addCommentDirect(reaction)"
|
|
>
|
|
<span class="text-h5">
|
|
{{ reaction }}
|
|
</span>
|
|
</v-btn>
|
|
</template>
|
|
</template>
|
|
</v-fade-transition>
|
|
<v-btn
|
|
v-tooltip="'Add attachments'"
|
|
:disabled="loading"
|
|
fab
|
|
small
|
|
class="mx-2 elevation-4"
|
|
@click="addAttachments()"
|
|
>
|
|
<v-icon v-if="$vuetify.breakpoint.smAndDown" small>mdi-camera</v-icon>
|
|
<v-icon v-else small>mdi-paperclip</v-icon>
|
|
</v-btn>
|
|
<v-btn
|
|
v-tooltip="'Send comment (press enter)'"
|
|
:disabled="loading"
|
|
icon
|
|
dark
|
|
fab
|
|
small
|
|
class="primary elevation-4"
|
|
@click="addComment()"
|
|
>
|
|
<v-icon dark small>mdi-send</v-icon>
|
|
</v-btn>
|
|
</div>
|
|
</v-dialog>
|
|
</div>
|
|
</v-slide-x-transition>
|
|
<portal to="viewercontrols" :order="100">
|
|
<v-slide-x-transition>
|
|
<v-btn
|
|
v-show="!location && !viewerState.selectedCommentMetaData"
|
|
v-tooltip="'Add a comment (ctrl + shift + c)'"
|
|
icon
|
|
dark
|
|
large
|
|
class="elevation-5 primary pa-0 ma-o"
|
|
@click="toggleExpand()"
|
|
>
|
|
<v-icon v-if="!expand" dark small>mdi-comment-plus</v-icon>
|
|
<v-icon v-else dark small>mdi-close</v-icon>
|
|
</v-btn>
|
|
</v-slide-x-transition>
|
|
</portal>
|
|
</div>
|
|
</template>
|
|
<script>
|
|
import * as THREE from 'three'
|
|
import { gql } from '@apollo/client/core'
|
|
import { debounce, throttle } from 'lodash'
|
|
import CommentEditor from '@/main/components/comments/CommentEditor.vue'
|
|
import {
|
|
basicStringToDocument,
|
|
isDocEmpty
|
|
} from '@/main/lib/common/text-editor/documentHelper'
|
|
import {
|
|
VIEWER_UPDATE_THROTTLE_TIME,
|
|
SMART_EDITOR_SCHEMA
|
|
} from '@/main/lib/viewer/comments/commentsHelper'
|
|
import { buildResizeHandlerMixin } from '@/main/lib/common/web-apis/mixins/windowResizeHandler'
|
|
import { isSuccessfullyUploaded } from '@/main/lib/common/file-upload/fileUploadHelper'
|
|
import { useInjectedViewer } from '@/main/lib/viewer/core/composables/viewer'
|
|
import { getCamArray } from '@/main/lib/viewer/core/helpers/cameraHelper'
|
|
import { useQuery } from '@vue/apollo-composable'
|
|
import { computed } from 'vue'
|
|
import {
|
|
setIsAddingComment,
|
|
useCommitObjectViewerParams,
|
|
getLocalFilterState
|
|
} from '@/main/lib/viewer/commit-object-viewer/stateManager'
|
|
import { ViewerEvent } from '@speckle/viewer'
|
|
/**
|
|
* TODO: Would be nice to get rid of duplicate templates for mobile & large screens
|
|
*/
|
|
|
|
export default {
|
|
components: { CommentEditor },
|
|
mixins: [
|
|
buildResizeHandlerMixin({ shouldThrottle: true, wait: VIEWER_UPDATE_THROTTLE_TIME })
|
|
],
|
|
apollo: {
|
|
user: {
|
|
query: gql`
|
|
query {
|
|
activeUser {
|
|
name
|
|
id
|
|
}
|
|
}
|
|
`,
|
|
update: (data) => data.activeUser,
|
|
skip() {
|
|
return !this.$loggedIn()
|
|
}
|
|
},
|
|
stream: {
|
|
query: gql`
|
|
query ($streamId: String!) {
|
|
stream(id: $streamId) {
|
|
id
|
|
role
|
|
allowPublicComments
|
|
}
|
|
}
|
|
`,
|
|
variables() {
|
|
return { streamId: this.streamId }
|
|
}
|
|
}
|
|
},
|
|
setup() {
|
|
const { streamId, resourceId } = useCommitObjectViewerParams()
|
|
const { viewer } = useInjectedViewer()
|
|
const { result: viewerStateResult } = useQuery(gql`
|
|
query {
|
|
commitObjectViewerState @client {
|
|
selectedCommentMetaData
|
|
commentReactions
|
|
# appliedFilter
|
|
currentFilterState
|
|
}
|
|
}
|
|
`)
|
|
const viewerState = computed(
|
|
() => viewerStateResult.value?.commitObjectViewerState || {}
|
|
)
|
|
|
|
return { viewer, viewerState, streamId, resourceId }
|
|
},
|
|
data() {
|
|
return {
|
|
location: null,
|
|
expand: false,
|
|
visible: true,
|
|
loading: false,
|
|
commentValue: { doc: null, attachments: [] },
|
|
editorSchemaOptions: SMART_EDITOR_SCHEMA,
|
|
anyAttachmentsProcessing: false
|
|
}
|
|
},
|
|
computed: {
|
|
canComment() {
|
|
return !!this.stream?.role || this.stream?.allowPublicComments
|
|
},
|
|
isCommentEmpty() {
|
|
return isDocEmpty(this.commentValue.doc) && !this.commentValue.attachments.length
|
|
},
|
|
isSubmitDisabled() {
|
|
return this.loading || this.anyAttachmentsProcessing
|
|
}
|
|
},
|
|
mounted() {
|
|
this.viewerSelectHandler = debounce(this.handleSelect, 10)
|
|
this.viewer.on(ViewerEvent.ObjectClicked, this.viewerSelectHandler)
|
|
|
|
// Throttling update, cause it happens way too often and triggers expensive DOM updates
|
|
// Smoothing out the animation with CSS transitions (check style)
|
|
this.viewerControlsUpdateHandler = throttle(() => {
|
|
this.updateCommentBubble()
|
|
}, VIEWER_UPDATE_THROTTLE_TIME)
|
|
this.viewer.cameraHandler.controls.addEventListener(
|
|
'update',
|
|
this.viewerControlsUpdateHandler
|
|
)
|
|
|
|
this.docKeyUpHandler = (e) => {
|
|
if (e.shiftKey && e.ctrlKey && e.keyCode === 67) this.toggleExpand()
|
|
}
|
|
document.addEventListener('keyup', this.docKeyUpHandler)
|
|
},
|
|
beforeDestroy() {
|
|
this.viewer.removeListener(ViewerEvent.ObjectClicked, this.viewerSelectHandler)
|
|
this.viewer.cameraHandler.controls.removeEventListener(
|
|
'update',
|
|
this.viewerControlsUpdateHandler
|
|
)
|
|
document.removeEventListener('keyup', this.docKeyUpHandler)
|
|
},
|
|
methods: {
|
|
onWindowResize() {
|
|
this.updateCommentBubble()
|
|
},
|
|
async addCommentDirect(emoji) {
|
|
this.commentValue.doc = basicStringToDocument(emoji, this.editorSchemaOptions)
|
|
await this.addComment()
|
|
},
|
|
addAttachments() {
|
|
const editor = this.$refs.desktopEditor || this.$refs.mobileEditor
|
|
editor.addAttachments()
|
|
},
|
|
async addComment() {
|
|
if (this.loading) return
|
|
if (this.isCommentEmpty) {
|
|
this.$eventHub.$emit('notification', {
|
|
text: `Comment cannot be empty.`
|
|
})
|
|
return
|
|
}
|
|
|
|
this.$mixpanel.track('Comment Action', { type: 'action', name: 'create' })
|
|
|
|
const camTarget = this.viewer.cameraHandler.activeCam.controls.getTarget()
|
|
|
|
const blobIds = this.commentValue.attachments
|
|
.filter(isSuccessfullyUploaded)
|
|
.map((a) => a.result.blobId)
|
|
const commentInput = {
|
|
streamId: this.streamId,
|
|
resources: [
|
|
{
|
|
resourceType: this.$route.path.includes('object') ? 'object' : 'commit',
|
|
resourceId: this.resourceId
|
|
}
|
|
],
|
|
text: this.commentValue.doc,
|
|
blobIds,
|
|
data: {
|
|
location: this.location
|
|
? this.location
|
|
: new THREE.Vector3(camTarget.x, camTarget.y, camTarget.z),
|
|
camPos: getCamArray(this.viewer),
|
|
filters: getLocalFilterState(),
|
|
sectionBox: this.viewer.getCurrentSectionBox(),
|
|
selection: null // Note: comments could keep track of selected objects, but for now we're too lazy to do so.
|
|
},
|
|
screenshot: await this.viewer.screenshot()
|
|
}
|
|
if (this.$route.query.overlay) {
|
|
commentInput.resources.push(
|
|
...this.$route.query.overlay
|
|
.split(',')
|
|
.map((res) => ({ resourceId: res, resourceType: this.$resourceType(res) }))
|
|
)
|
|
}
|
|
|
|
let success = false
|
|
this.loading = true
|
|
try {
|
|
const { data } = await this.$apollo.mutate({
|
|
mutation: gql`
|
|
mutation commentCreate($input: CommentCreateInput!) {
|
|
commentCreate(input: $input)
|
|
}
|
|
`,
|
|
variables: { input: commentInput }
|
|
})
|
|
success = !!data.commentCreate
|
|
} catch (e) {
|
|
this.$eventHub.$emit('notification', {
|
|
text: e.message
|
|
})
|
|
}
|
|
|
|
// On success, mark uploads as in use, to prevent cleanup
|
|
if (success) {
|
|
this.commentValue.attachments.forEach((a) => {
|
|
a.inUse = true
|
|
})
|
|
}
|
|
|
|
this.loading = false
|
|
this.expand = false
|
|
this.visible = false
|
|
this.commentValue = { doc: null, attachments: [] }
|
|
setIsAddingComment(false)
|
|
this.viewer.resetSelection()
|
|
},
|
|
sendStatusUpdate() {
|
|
// TODO: typing or not
|
|
},
|
|
toggleExpand() {
|
|
this.expand = !this.expand
|
|
if (this.expand && !this.location) {
|
|
this.visible = true
|
|
this.$refs.commentButton.style.transform = `translate(-50%, -50%)`
|
|
this.$refs.commentButton.style.top = `50%`
|
|
this.$refs.commentButton.style.left = `50%`
|
|
}
|
|
|
|
if (!this.location && !this.expand) this.visible = false
|
|
|
|
setIsAddingComment(this.expand)
|
|
},
|
|
handleSelect(info) {
|
|
this.expand = false
|
|
if (!info || !info.hits.length === 0) {
|
|
this.visible = false
|
|
this.location = null
|
|
setIsAddingComment(false)
|
|
return
|
|
}
|
|
|
|
if (!this.$refs.commentButton) return
|
|
this.visible = true
|
|
|
|
const projectedLocation = new THREE.Vector3(
|
|
info.hits[0].point.x,
|
|
info.hits[0].point.y,
|
|
info.hits[0].point.z
|
|
)
|
|
this.location = new THREE.Vector3(
|
|
info.hits[0].point.x,
|
|
info.hits[0].point.y,
|
|
info.hits[0].point.z
|
|
)
|
|
|
|
const cam = this.viewer.cameraHandler.camera
|
|
cam.updateProjectionMatrix()
|
|
projectedLocation.project(cam)
|
|
let collapsedSize = this.$refs.commentButton.clientWidth
|
|
collapsedSize = 36
|
|
const mappedLocation = new THREE.Vector3(
|
|
(projectedLocation.x * 0.5 + 0.5) * this.$refs.parent.clientWidth -
|
|
collapsedSize / 2,
|
|
(projectedLocation.y * -0.5 + 0.5) * this.$refs.parent.clientHeight -
|
|
collapsedSize / 1,
|
|
0
|
|
)
|
|
this.$refs.commentButton.style.transform = ''
|
|
this.$refs.commentButton.style.transition = 'all 0.3s ease'
|
|
this.$refs.commentButton.style.top = `${mappedLocation.y - 7}px`
|
|
this.$refs.commentButton.style.left = `${mappedLocation.x}px`
|
|
},
|
|
updateCommentBubble() {
|
|
if (!this.location) return
|
|
if (!this.$refs.commentButton) return
|
|
const cam = this.viewer.cameraHandler.activeCam.camera
|
|
cam.updateProjectionMatrix()
|
|
const projectedLocation = this.location.clone()
|
|
projectedLocation.project(cam)
|
|
let collapsedSize = this.$refs.commentButton.clientWidth
|
|
collapsedSize = 36
|
|
const mappedLocation = new THREE.Vector3(
|
|
(projectedLocation.x * 0.5 + 0.5) * this.$refs.parent.clientWidth -
|
|
collapsedSize / 2,
|
|
(projectedLocation.y * -0.5 + 0.5) * this.$refs.parent.clientHeight -
|
|
collapsedSize / 1,
|
|
0
|
|
)
|
|
this.$refs.commentButton.style.transform = ''
|
|
this.$refs.commentButton.style.transition = ''
|
|
this.$refs.commentButton.style.top = `${mappedLocation.y - 7}px`
|
|
this.$refs.commentButton.style.left = `${mappedLocation.x}px`
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<style scoped lang="scss">
|
|
:deep(.v-dialog) {
|
|
box-shadow: none;
|
|
overflow-y: hidden;
|
|
overflow-x: hidden;
|
|
}
|
|
.no-mouse {
|
|
pointer-events: none;
|
|
}
|
|
|
|
.mouse {
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.absolute-pos {
|
|
position: absolute;
|
|
top: -100px;
|
|
left: -100px;
|
|
}
|
|
|
|
.transition {
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.new-comment-overlay {
|
|
$timing: 0.1s;
|
|
transition: left $timing linear, right $timing linear, top $timing linear,
|
|
bottom $timing linear;
|
|
}
|
|
</style>
|