Browse Source

Fixes #4738 - Inline images are not resized during the usage of the editor

Vladimir Sheremet 1 year ago
parent
commit
d813613844

+ 1 - 0
app/frontend/apps/mobile/pages/account/views/AccountAvatar.vue

@@ -178,6 +178,7 @@ useHeader({
 
 const loadAvatar = async (input?: HTMLInputElement) => {
   const files = input?.files
+  if (!files) return
   const [avatar] = await convertFileList(files)
   avatarImage.value = avatar
 }

+ 70 - 10
app/frontend/shared/components/Form/fields/FieldEditor/FieldEditorInput.vue

@@ -2,7 +2,6 @@
 
 <script setup lang="ts">
 import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
-import { convertFileList } from '#shared/utils/files.ts'
 import type { Editor } from '@tiptap/vue-3'
 import { useEditor, EditorContent } from '@tiptap/vue-3'
 import { useEventListener } from '@vueuse/core'
@@ -25,6 +24,9 @@ import type {
 import FieldEditorActionBar from './FieldEditorActionBar.vue'
 import FieldEditorFooter from './FieldEditorFooter.vue'
 import { PLUGIN_NAME as userMentionPluginName } from './suggestions/UserMention.ts'
+import { getNodeByName } from '../../utils.ts'
+import type { FieldFileContext } from '../FieldFile/types.ts'
+import { convertInlineImages } from './utils.ts'
 
 interface Props {
   context: FormFieldContext<FieldEditorProps>
@@ -61,9 +63,58 @@ getCustomExtensions(reactiveContext).forEach((extension) => {
   }
 })
 
+const hasImageExtension = editorExtensions.some(
+  (extension) => extension.name === 'image',
+)
 const showActionBar = ref(false)
 const editorValue = ref<string>(VITE_TEST_MODE ? props.context._value : '')
 
+interface LoadImagesOptions {
+  attachNonInlineFiles: boolean
+}
+
+// there is also a gif, but desktop only inlines these two for now
+const imagesMimeType = ['image/png', 'image/jpeg']
+const loadFiles = (
+  files: FileList | null | undefined,
+  editor: Editor | undefined,
+  options: LoadImagesOptions,
+) => {
+  if (!files) {
+    return false
+  }
+
+  const inlineImages: File[] = []
+  const otherFiles: File[] = []
+
+  for (const file of files) {
+    if (imagesMimeType.includes(file.type)) {
+      inlineImages.push(file)
+    } else {
+      otherFiles.push(file)
+    }
+  }
+
+  if (inlineImages.length && editor) {
+    convertInlineImages(inlineImages, editor.view.dom).then((urls) => {
+      if (editor?.isDestroyed) return
+      editor?.commands.setImages(urls)
+    })
+  }
+
+  if (options.attachNonInlineFiles && otherFiles.length) {
+    const attachmentsContext = getNodeByName(
+      props.context.formId,
+      'attachments',
+    )?.context as unknown as FieldFileContext | undefined
+    attachmentsContext?.uploadFiles(otherFiles)
+  }
+
+  return Boolean(
+    inlineImages.length || (options.attachNonInlineFiles && otherFiles.length),
+  )
+}
+
 const editor = useEditor({
   extensions: editorExtensions,
   editorProps: {
@@ -76,15 +127,14 @@ const editor = useEditor({
     },
     // add inlined files
     handlePaste(view, event) {
-      if (!editorExtensions.some((n) => n.name === 'image')) {
+      if (!hasImageExtension) {
         return
       }
-      const files = event.clipboardData?.files || null
-      convertFileList(files).then((urls) => {
-        editor.value?.commands.setImages(urls)
+      const loaded = loadFiles(event.clipboardData?.files, editor.value, {
+        attachNonInlineFiles: false,
       })
 
-      if (files && files.length) {
+      if (loaded) {
         event.preventDefault()
         return true
       }
@@ -92,15 +142,15 @@ const editor = useEditor({
       return false
     },
     handleDrop(view, event) {
-      if (!editorExtensions.some((n) => n.name === 'image')) {
+      if (!hasImageExtension) {
         return
       }
       const e = event as unknown as InputEvent
       const files = e.dataTransfer?.files || null
-      convertFileList(files).then((urls) => {
-        editor.value?.commands.setImages(urls)
+      const loaded = loadFiles(files, editor.value, {
+        attachNonInlineFiles: true,
       })
-      if (files && files.length) {
+      if (loaded) {
         event.preventDefault()
         return true
       }
@@ -274,6 +324,16 @@ onMounted(() => {
   ) => void)[]
   onLoad.forEach((fn) => fn(editorCustomContext))
   onLoad.length = 0
+
+  if (VITE_TEST_MODE) {
+    if (!('editors' in globalThis))
+      Object.defineProperty(globalThis, 'editors', { value: {} })
+    Object.defineProperty(
+      Reflect.get(globalThis, 'editors'),
+      props.context.node.name,
+      { value: editor.value, configurable: true },
+    )
+  }
 })
 </script>
 

+ 5 - 1
app/frontend/shared/components/Form/fields/FieldEditor/ImageResizable.vue

@@ -20,7 +20,11 @@ const imageLoaded = ref(false)
 const isDraggable = computed(() => props.node.attrs.isDraggable)
 const src = computed(() => props.node.attrs.src)
 if (needBase64Convert(src.value)) {
-  loadImageIntoBase64(src.value, props.node.attrs.alt).then((base64) => {
+  loadImageIntoBase64(
+    src.value,
+    props.node.attrs.type,
+    props.node.attrs.alt,
+  ).then((base64) => {
     if (base64) {
       props.updateAttributes({ src: base64 })
     } else {

+ 6 - 0
app/frontend/shared/components/Form/fields/FieldEditor/extensions/Image.ts

@@ -33,6 +33,11 @@ export default Image.extend({
           return {}
         },
       },
+
+      type: {
+        default: null,
+        renderHTML: () => ({}),
+      },
     }
   },
   addNodeView() {
@@ -51,6 +56,7 @@ export default Image.extend({
                 attrs: {
                   src: image.content,
                   alt: image.name,
+                  type: image.type,
                 },
               })),
               {

+ 9 - 4
app/frontend/shared/components/Form/fields/FieldEditor/useEditorActions.ts

@@ -1,7 +1,6 @@
 // Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
 
 import { i18n } from '#shared/i18n.ts'
-import { convertFileList } from '#shared/utils/files.ts'
 import type { ChainedCommands } from '@tiptap/core'
 import type { Editor } from '@tiptap/vue-3'
 import { computed, onUnmounted } from 'vue'
@@ -11,6 +10,7 @@ import { PLUGIN_NAME as KnowledgeBaseMentionName } from './suggestions/Knowledge
 import { PLUGIN_NAME as TextModuleMentionName } from './suggestions/TextModuleSuggestion.ts'
 import { PLUGIN_NAME as UserMentionName } from './suggestions/UserMention.ts'
 import type { EditorContentType } from './types.ts'
+import { convertInlineImages } from './utils.ts'
 
 export interface EditorButton {
   name: string
@@ -62,8 +62,10 @@ export default function useEditorActions(
 
   onUnmounted(() => {
     fileInput?.remove()
+    fileInput = null
   })
 
+  // eslint-disable-next-line sonarjs/cognitive-complexity
   const getActionsList = (): EditorButton[] => {
     return [
       {
@@ -102,12 +104,15 @@ export default function useEditorActions(
         command: focused((c) => {
           const input = getInputForImage()
           input.onchange = async () => {
-            if (!input.files?.length) return
-            const files = await convertFileList(input.files)
+            if (!input.files?.length || !editor.value) return
+            const files = await convertInlineImages(
+              input.files,
+              editor.value.view.dom,
+            )
             c.setImages(files).run()
             input.value = ''
           }
-          input.click()
+          if (!VITE_TEST_MODE) input.click()
         }),
       },
       {

+ 20 - 0
app/frontend/shared/components/Form/fields/FieldEditor/utils.ts

@@ -1,5 +1,7 @@
 // Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
 
+import { convertFileList } from '#shared/utils/files.ts'
+
 export const populateEditorNewLines = (htmlContent: string): string => {
   const body = document.createElement('div')
   body.innerHTML = htmlContent
@@ -17,3 +19,21 @@ export const populateEditorNewLines = (htmlContent: string): string => {
   })
   return body.innerHTML
 }
+
+export const convertInlineImages = (
+  inlineImages: FileList | File[],
+  editorElement: HTMLElement,
+) => {
+  return convertFileList(inlineImages, {
+    compress: true,
+    onCompress: () => {
+      const editorWidth = editorElement.clientWidth
+      const maxWidth = editorWidth > 1000 ? editorWidth : 1000
+      return {
+        x: maxWidth,
+        scale: 2,
+        type: 'image/jpeg',
+      }
+    },
+  })
+}

+ 16 - 7
app/frontend/shared/components/Form/fields/FieldFile/FieldFileInput.vue

@@ -50,16 +50,15 @@ const canInteract = computed(
 
 const fileInput = ref<HTMLInputElement>()
 
-const reset = (input: HTMLInputElement) => {
+const reset = () => {
   loadingFiles.value = []
+  const input = fileInput.value
+  if (!input) return
   input.value = ''
   input.files = null
 }
 
-const onFileChanged = async ($event: Event) => {
-  const input = $event.target as HTMLInputElement
-  const { files } = input
-
+const loadFiles = async (files: FileList | File[]) => {
   loadingFiles.value = Array.from(files || []).map((file) => ({
     name: file.name,
     size: file.size,
@@ -76,7 +75,7 @@ const onFileChanged = async ($event: Event) => {
   const uploadedFiles = data?.formUploadCacheAdd?.uploadedFiles
 
   if (!uploadedFiles) {
-    reset(input)
+    reset()
     return
   }
 
@@ -86,7 +85,17 @@ const onFileChanged = async ($event: Event) => {
   }))
 
   uploadFiles.value = [...uploadFiles.value, ...previewableFile]
-  reset(input)
+  reset()
+}
+
+Object.assign(props.context, {
+  uploadFiles: loadFiles,
+})
+
+const onFileChanged = async ($event: Event) => {
+  const input = $event.target as HTMLInputElement
+  const { files } = input
+  if (files) await loadFiles(files)
 }
 
 const { waitForConfirmation } = useConfirmationDialog()

+ 4 - 0
app/frontend/shared/components/Form/fields/FieldFile/types.ts

@@ -14,3 +14,7 @@ export type FileUploaded = Pick<StoredFile, 'name' | 'size' | 'type'> & {
   content?: string
   preview?: string
 }
+
+export interface FieldFileContext {
+  uploadFiles(files: FileList | File[]): Promise<void>
+}

+ 134 - 4
app/frontend/shared/utils/files.ts

@@ -2,6 +2,7 @@
 
 import type { FileUploaded } from '#shared/components/Form/fields/FieldFile/types.ts'
 import { useApplicationStore } from '#shared/stores/application.ts'
+import log from './log.ts'
 
 export interface ImageFileData {
   name: string
@@ -9,6 +10,126 @@ export interface ImageFileData {
   content: string
 }
 
+interface CompressData {
+  x?: number | 'auto'
+  y?: number | 'auto'
+  scale?: number
+  type?: string
+  quality?: number | 'auto'
+}
+
+interface CompressOptions {
+  compress?: boolean
+  onCompress?(image: HTMLImageElement, type: string): CompressData
+}
+
+const allowCompressMime = ['image/jpeg', 'image/png']
+
+const getQuality = (x: number, y: number) => {
+  if (x < 200 && y < 200) return 1
+  if (x < 400 && y < 400) return 0.9
+  if (x < 600 && y < 600) return 0.8
+  if (x < 900 && y < 900) return 0.7
+  return 0.6
+}
+
+export const compressImage = (
+  imageSrc: string,
+  type: string,
+  options?: CompressOptions,
+) => {
+  const img = new Image()
+  // eslint-disable-next-line sonarjs/cognitive-complexity
+  const promise = new Promise<string>((resolve) => {
+    img.onload = () => {
+      const {
+        x: imgX = 'auto',
+        y: imgY = 'auto',
+        quality = 'auto',
+        scale = 1,
+        type: mimeType = type,
+      } = options?.onCompress?.(img, type) || {}
+
+      const imageWidth = img.width
+      const imageHeight = img.height
+
+      log.debug('[Image Service] Image is loaded', {
+        imageWidth,
+        imageHeight,
+      })
+
+      let x = imgX
+      let y = imgY
+
+      if (y === 'auto' && x === 'auto') {
+        x = imageWidth
+        y = imageHeight
+      }
+
+      // set max x/y
+      if (x !== 'auto' && x > imageWidth) x = imageWidth
+
+      if (y !== 'auto' && y > imageHeight) y = imageHeight
+
+      // get auto dimensions
+      if (y === 'auto') {
+        const factor = imageWidth / (x as number)
+        y = imageHeight / factor
+      }
+
+      if (x === 'auto') {
+        const factor = imageHeight / y
+        x = imageWidth / factor
+      }
+
+      const canvas = document.createElement('canvas')
+
+      if (
+        (x < imageWidth && x * scale < imageWidth) ||
+        (y < imageHeight && y * scale < imageHeight)
+      ) {
+        x *= scale
+        y *= scale
+
+        // set dimensions
+        canvas.width = x
+        canvas.height = y
+
+        // draw image on canvas and set image dimensions
+        const context = canvas.getContext('2d') as CanvasRenderingContext2D
+        context.drawImage(img, 0, 0, x, y)
+      } else {
+        canvas.width = imageWidth
+        canvas.height = imageHeight
+
+        const context = canvas.getContext('2d') as CanvasRenderingContext2D
+        context.drawImage(img, 0, 0, imageWidth, imageHeight)
+      }
+
+      const qualityValue =
+        quality === 'auto' ? getQuality(imageWidth, imageHeight) : quality
+
+      try {
+        const base64 = canvas.toDataURL(mimeType, qualityValue)
+        log.debug('[Image Service] Image is compressed', {
+          quality: qualityValue,
+          type: mimeType,
+          x,
+          y,
+          size: `${(base64.length * 0.75) / 1024 / 1024} Mb`,
+        })
+        resolve(base64)
+      } catch (e) {
+        log.debug('[Image Service] Failed to compress an image', e)
+        resolve(imageSrc)
+      }
+    }
+    img.onerror = () => resolve(imageSrc)
+  })
+  img.src = imageSrc
+  return promise
+}
+
 export const blobToBase64 = async (blob: Blob) =>
   new Promise<string>((resolve, reject) => {
     const reader = new FileReader()
@@ -18,15 +139,22 @@ export const blobToBase64 = async (blob: Blob) =>
   })
 
 export const convertFileList = async (
-  filesList?: Maybe<FileList>,
+  filesList: Maybe<FileList | File[]>,
+  options: CompressOptions = {},
 ): Promise<ImageFileData[]> => {
   const files = Array.from(filesList || [])
 
   const promises = files.map(async (file) => {
+    let base64 = await blobToBase64(file)
+
+    if (options?.compress && allowCompressMime.includes(file.type)) {
+      base64 = await compressImage(base64, file.type, options)
+    }
+
     return {
       name: file.name,
       type: file.type,
-      content: await blobToBase64(file),
+      content: base64,
     }
   })
 
@@ -37,6 +165,7 @@ export const convertFileList = async (
 
 export const loadImageIntoBase64 = async (
   src: string,
+  type?: string,
   alt?: string,
 ): Promise<string | null> => {
   const img = new Image()
@@ -48,7 +177,8 @@ export const loadImageIntoBase64 = async (
       canvas.height = img.height
       const ctx = canvas.getContext('2d')
       ctx?.drawImage(img, 0, 0, img.width, img.height)
-      const mime = img.alt?.match(/\.(jpe?g)$/i) ? 'image/jpeg' : 'image/png'
+      const mime =
+        type || (img.alt?.match(/\.(jpe?g)$/i) ? 'image/jpeg' : 'image/png')
       try {
         const base64 = canvas.toDataURL(mime)
         resolve(base64)
@@ -75,7 +205,7 @@ export const canPreviewFile = (type?: Maybe<string>) => {
   const { config } = useApplicationStore()
 
   const allowedPreviewContentTypes =
-    (config['active_storage.web_image_content_types'] as string[]) || []
+    config['active_storage.web_image_content_types'] || []
 
   return allowedPreviewContentTypes.includes(type)
 }

+ 29 - 0
app/frontend/tests/vitest.setup.ts

@@ -48,6 +48,35 @@ Object.defineProperty(Element.prototype, 'scroll', { value: vi.fn() })
 Object.defineProperty(Element.prototype, 'scrollBy', { value: vi.fn() })
 Object.defineProperty(Element.prototype, 'scrollIntoView', { value: vi.fn() })
 
+const descriptor = Object.getOwnPropertyDescriptor(
+  HTMLImageElement.prototype,
+  'src',
+)!
+
+Object.defineProperty(HTMLImageElement.prototype, 'src', {
+  set(value) {
+    descriptor.set?.call(this, value)
+    this.dispatchEvent(new Event('load'))
+  },
+  get: descriptor.get,
+})
+
+Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
+  value: function getContext() {
+    return {
+      drawImage: (img: HTMLImageElement) => {
+        this.__image_src = img.src
+      },
+    }
+  },
+})
+
+Object.defineProperty(HTMLCanvasElement.prototype, 'toDataURL', {
+  value: function toDataURL() {
+    return this.__image_src
+  },
+})
+
 // Mock IntersectionObserver feature by injecting it into the global namespace.
 //   More info here: https://vitest.dev/guide/mocking.html#globals
 const IntersectionObserverMock = vi.fn(() => ({

Some files were not shown because too many files changed in this diff