ArticleBubbleBody.vue 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. <!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { computed, toRef, watch, nextTick, onMounted } from 'vue'
  4. import { useArticleToggleMore } from '#shared/composables/useArticleToggleMore.ts'
  5. import { useHtmlInlineImages } from '#shared/composables/useHtmlInlineImages.ts'
  6. import { useHtmlLinks } from '#shared/composables/useHtmlLinks.ts'
  7. import { type ImageViewerFile } from '#shared/composables/useImageViewer.ts'
  8. import type { TicketArticle } from '#shared/entities/ticket/types.ts'
  9. import { textToHtml } from '#shared/utils/helpers.ts'
  10. import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
  11. interface Props {
  12. article: TicketArticle
  13. showMetaInformation: boolean
  14. position: 'left' | 'right'
  15. inlineImages: ImageViewerFile[]
  16. }
  17. const props = defineProps<Props>()
  18. const emit = defineEmits<{
  19. preview: [image: ImageViewerFile]
  20. }>()
  21. const { shownMore, bubbleElement, hasShowMore, toggleShowMore } =
  22. useArticleToggleMore()
  23. const bodyClasses = computed(() =>
  24. props.position === 'right'
  25. ? ['dark:bg-stone-500', 'bg-blue-100', 'Content--customer']
  26. : ['dark:bg-gray-400', 'bg-white', 'Content--agent'],
  27. )
  28. const body = computed(() => {
  29. if (props.article.contentType !== 'text/html') {
  30. return textToHtml(props.article.bodyWithUrls)
  31. }
  32. return props.article.bodyWithUrls
  33. })
  34. const showAuthorInformation = computed(() => {
  35. const author = props.article.author.fullname // `-` => system message
  36. return (
  37. !props.showMetaInformation && author !== '-' && (author?.length ?? 0) > 0
  38. )
  39. })
  40. const { setupLinksHandlers } = useHtmlLinks('/desktop')
  41. const { populateInlineImages } = useHtmlInlineImages(
  42. toRef(props, 'inlineImages'),
  43. (index) => emit('preview', props.inlineImages[index]),
  44. )
  45. watch(
  46. () => body,
  47. async () => {
  48. await nextTick()
  49. if (bubbleElement.value) {
  50. setupLinksHandlers(bubbleElement.value)
  51. populateInlineImages(bubbleElement.value)
  52. }
  53. },
  54. )
  55. onMounted(() => {
  56. if (bubbleElement.value) {
  57. setupLinksHandlers(bubbleElement.value)
  58. populateInlineImages(bubbleElement.value)
  59. }
  60. })
  61. </script>
  62. <template>
  63. <div
  64. class="Content -:p-3 relative transition-[padding]"
  65. :class="[
  66. bodyClasses,
  67. {
  68. 'pt-3': showMetaInformation,
  69. '-:pt-9': showAuthorInformation,
  70. },
  71. ]"
  72. >
  73. <div
  74. v-if="showAuthorInformation"
  75. class="absolute top-3 flex w-full px-3 ltr:left-0 rtl:right-0"
  76. role="group"
  77. aria-describedby="author-name-and-creation-date"
  78. >
  79. <p id="author-name-and-creation-date" class="sr-only">
  80. {{ $t('Author name and article creation date') }}
  81. </p>
  82. <CommonLabel class="font-bold" size="small" variant="neutral">
  83. {{ article.author.fullname }}
  84. </CommonLabel>
  85. <CommonDateTime
  86. class="text-xs ltr:ml-auto rtl:mr-auto"
  87. :date-time="article.createdAt"
  88. />
  89. </div>
  90. <div
  91. ref="bubbleElement"
  92. data-test-id="article-content"
  93. class="overflow-hidden text-sm"
  94. >
  95. <!-- eslint-disable vue/no-v-html-->
  96. <div class="inner-article-body" v-html="body" />
  97. </div>
  98. <div
  99. v-if="hasShowMore"
  100. class="relative"
  101. :class="{
  102. BubbleGradient: hasShowMore && !shownMore,
  103. }"
  104. />
  105. <CommonButton
  106. v-if="hasShowMore"
  107. class="!p-0 !outline-transparent"
  108. size="medium"
  109. @click.prevent="toggleShowMore"
  110. @keydown.enter.prevent="toggleShowMore"
  111. >
  112. {{ shownMore ? $t('See less') : $t('See more') }}
  113. </CommonButton>
  114. </div>
  115. </template>
  116. <style scoped>
  117. /*
  118. * Currently, we only set the style for img and svg elements.
  119. * If necessary, this may need to be extended with other elements listed bellow.
  120. *
  121. * Relevant elements include:
  122. * - img, svg, canvas, audio, iframe, embed, object
  123. *
  124. * These elements inherit a `display: block` style from the root stylesheet.
  125. */
  126. .inner-article-body {
  127. :deep(img, svg) {
  128. display: inline;
  129. }
  130. }
  131. .BubbleGradient::before {
  132. content: '';
  133. position: absolute;
  134. left: 0;
  135. right: 0;
  136. bottom: 0;
  137. height: 46px;
  138. pointer-events: none;
  139. }
  140. .Content--agent .BubbleGradient::before {
  141. background: linear-gradient(rgba(255, 255, 255, 0), theme('colors.white'));
  142. }
  143. [data-theme='dark'] .Content--agent .BubbleGradient::before {
  144. background: linear-gradient(rgba(255, 255, 255, 0), theme('colors.gray.400'));
  145. }
  146. .Content--customer .BubbleGradient::before {
  147. background: linear-gradient(rgba(255, 255, 255, 0), theme('colors.blue.100'));
  148. }
  149. [data-theme='dark'] .Content--customer .BubbleGradient::before {
  150. background: linear-gradient(
  151. rgba(255, 255, 255, 0),
  152. theme('colors.stone.500')
  153. );
  154. }
  155. </style>