Skip to content

Commit ce600e3

Browse files
authored
feat(tiptap): add insert image from cloud (#3024)
1 parent 13c98b3 commit ce600e3

6 files changed

Lines changed: 108 additions & 5 deletions

File tree

‎packages/web-app-text-editor/src/App.vue‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const placeholder = computed(() => {
5959
6060
const textEditor = useTextEditor({
6161
contentType: unref(parsedContentType),
62+
currentResource: toRef(() => resource),
6263
modelValue: toRef(() => currentContent),
6364
readonly: isReadOnly,
6465
placeholder: unref(placeholder),

‎packages/web-pkg/src/editor/composables/useEditorActions.ts‎

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
import { markRaw, ref, unref } from 'vue'
1+
import { computed, markRaw, ref, unref } from 'vue'
22
import type { Component } from 'vue'
33
import type { Editor, Range } from '@tiptap/core'
44
import type {} from '@tiptap/extension-text-align'
55
import { useGettext } from 'vue3-gettext'
66
import { storeToRefs } from 'pinia'
7+
import type { Resource } from '@opencloud-eu/web-client'
78
import { OcEmojiPicker } from '@opencloud-eu/design-system/components'
89
import { useModals, useThemeStore } from '../../composables'
10+
import { useClientService } from '../../composables/clientService'
11+
import { useGetMatchingSpace } from '../../composables/spaces'
12+
import { useFolderLink } from '../../composables/folderLink'
13+
import FilePickerModal from '../../components/Modals/FilePickerModal.vue'
14+
import { arrayBufferToDataUrl } from '../../helpers'
915
import { TextEditorState } from '../types'
1016
import { requestLinkPanel } from '../helpers/link'
1117
import TextEditorSearchAndReplacePanel from '../components/TextEditorSearchAndReplacePanel.vue'
@@ -64,6 +70,12 @@ export function useEditorActions(state: TextEditorState) {
6470
const { dispatchModal } = useModals()
6571
const themeStore = useThemeStore()
6672
const { currentTheme } = storeToRefs(themeStore)
73+
const clientService = useClientService()
74+
const { getMatchingSpace } = useGetMatchingSpace()
75+
const { getParentFolderLink } = useFolderLink()
76+
const currentResource = computed<Resource | null>(() => {
77+
return unref(state.currentResource) ?? null
78+
})
6779

6880
//Search
6981
const searchSearchTerm = ref('')
@@ -544,6 +556,47 @@ export function useEditorActions(state: TextEditorState) {
544556
})
545557
}
546558

559+
const insertImageFromCloudResource = async (editor: Editor, resource: Resource) => {
560+
const space = getMatchingSpace(resource)
561+
const response = await clientService.webdav.getFileContents(
562+
space,
563+
{ path: resource.path },
564+
{ responseType: 'arraybuffer' }
565+
)
566+
const body = response?.body as ArrayBuffer | undefined
567+
if (!body) {
568+
return
569+
}
570+
const mimeType = resource.mimeType || 'application/octet-stream'
571+
const dataUrl = await arrayBufferToDataUrl(body, mimeType)
572+
editor.chain().focus().setImage({ src: dataUrl }).run()
573+
}
574+
575+
const openCloudImagePicker = (editor: Editor) => {
576+
const resource = unref(currentResource)
577+
if (!resource) {
578+
return
579+
}
580+
581+
dispatchModal({
582+
elementClass: 'file-picker-modal',
583+
title: $gettext('Insert image from cloud'),
584+
customComponent: markRaw(FilePickerModal),
585+
hideActions: true,
586+
customComponentAttrs: () => ({
587+
allowedFileTypes: ['image/png', 'image/gif', 'image/jpeg', 'image/svg'],
588+
parentFolderLink: getParentFolderLink(resource),
589+
callbackFn: async ({ resource }: { resource: Resource }) => {
590+
if (!resource.mimeType?.startsWith('image/')) {
591+
return
592+
}
593+
await insertImageFromCloudResource(editor, resource)
594+
}
595+
}),
596+
focusTrapInitial: false
597+
})
598+
}
599+
547600
const imageUrl = (): EditorAction => ({
548601
id: 'image-url',
549602
title: $gettext('Image from URL'),
@@ -575,13 +628,28 @@ export function useEditorActions(state: TextEditorState) {
575628
isActive: () => false
576629
})
577630

631+
const imageCloud = (): EditorAction => ({
632+
id: 'image-cloud',
633+
title: $gettext('Insert from cloud'),
634+
description: $gettext('Insert an image from your cloud files'),
635+
icon: 'cloud-line',
636+
keywords: ['image', 'picture', 'cloud'],
637+
showInSlashCommands: false,
638+
toolbarAction: (editor) => openCloudImagePicker(editor),
639+
slashCommandAction: ({ editor, range }) => {
640+
editor.chain().focus().deleteRange(range).run()
641+
openCloudImagePicker(editor)
642+
},
643+
isActive: () => false
644+
})
645+
578646
const image = (): EditorAction => ({
579647
id: 'image',
580648
title: $gettext('Insert image'),
581649
icon: 'image-line',
582-
keywords: ['image', 'picture', 'upload', 'url'],
650+
keywords: ['image', 'picture', 'upload', 'url', 'cloud'],
583651
showInSlashCommands: false,
584-
childActions: [imageUpload(), imageUrl()],
652+
childActions: [imageUpload(), imageUrl(), imageCloud()],
585653
isActive: () => false
586654
})
587655

‎packages/web-pkg/src/editor/composables/useTextEditor.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useEditor } from '@tiptap/vue-3'
33
import { Placeholder } from '@tiptap/extension-placeholder'
44
import type { ShallowRef } from 'vue'
55
import type { Editor } from '@tiptap/vue-3'
6+
import type { Resource } from '@opencloud-eu/web-client'
67
import type {
78
TextEditorOptions,
89
TextEditorInstance,
@@ -18,7 +19,8 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance {
1819
const state: TextEditorState = {
1920
sourceMode: ref(false),
2021
linkPanel: ref<TextEditorLinkPanelRequest | null>(null),
21-
editorZoom: ref(100)
22+
editorZoom: ref(100),
23+
currentResource: options.currentResource ?? ref<Resource | null>(null)
2224
}
2325

2426
const contentType = ref(options.contentType)

‎packages/web-pkg/src/editor/types.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import type { ShallowRef, Ref, ComputedRef } from 'vue'
22
import type { Range } from '@tiptap/core'
33
import type { Editor } from '@tiptap/vue-3'
4+
import type { Resource } from '@opencloud-eu/web-client'
45
import type { EditorActionGroup } from './composables'
56

67
export type ContentType = 'plain-text' | 'markdown' | 'html' | 'tiptap-json'
78

89
export interface TextEditorOptions {
910
contentType: ContentType
1011
modelValue?: Ref<string>
12+
currentResource?: Ref<Resource>
1113
readonly?: boolean
1214
slashCommands?: boolean
1315
placeholder?: string
@@ -31,6 +33,7 @@ export interface TextEditorState {
3133
sourceMode: Ref<boolean>
3234
linkPanel: Ref<TextEditorLinkPanelRequest | null>
3335
editorZoom: Ref<number>
36+
currentResource?: Ref<Resource | null>
3437
}
3538

3639
export interface TextEditorInstance {

‎packages/web-pkg/src/helpers/binary.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,12 @@ export const blobToArrayBuffer: (blob: Blob) => Promise<string | ArrayBuffer> =
1010
export const canvasToBlob = (canvas: HTMLCanvasElement): Promise<Blob> => {
1111
return new Promise((resolve) => canvas.toBlob(resolve))
1212
}
13+
14+
export const arrayBufferToDataUrl = (buffer: ArrayBuffer, mimeType: string): Promise<string> => {
15+
return new Promise((resolve, reject) => {
16+
const reader = new FileReader()
17+
reader.addEventListener('load', () => resolve(reader.result as string))
18+
reader.addEventListener('error', () => reject(reader.error))
19+
reader.readAsDataURL(new Blob([buffer], { type: mimeType }))
20+
})
21+
}

‎packages/web-pkg/tests/unit/editor/composables/useEditorActions.spec.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ref } from 'vue'
33
import type { Range } from '@tiptap/core'
44
import { Editor } from '@tiptap/vue-3'
55
import StarterKit from '@tiptap/starter-kit'
6+
import { mock } from 'vitest-mock-extended'
67
import { createMockEditor } from './helpers'
78

89
vi.mock('vue3-gettext', () => ({
@@ -11,6 +12,7 @@ vi.mock('vue3-gettext', () => ({
1112

1213
import { useEditorActions } from '../../../../src/editor/composables/useEditorActions'
1314
import type { TextEditorLinkPanelRequest, TextEditorState } from '../../../../src/editor/types'
15+
import type { Resource } from '@opencloud-eu/web-client'
1416
import { createTestingPinia } from '@opencloud-eu/web-test-helpers'
1517
import { useModals } from '../../../../src/composables/piniaStores'
1618
import { createLinkExtension } from '../../../../src/editor/extensions/link'
@@ -19,7 +21,8 @@ function createState(): TextEditorState {
1921
return {
2022
sourceMode: ref(false),
2123
linkPanel: ref<TextEditorLinkPanelRequest | null>(null),
22-
editorZoom: ref(100)
24+
editorZoom: ref(100),
25+
currentResource: ref<Resource | null>(mock<Resource>({ id: 'resource', path: '/' }))
2326
}
2427
}
2528

@@ -814,6 +817,23 @@ describe('useEditorActions', () => {
814817
})
815818
})
816819

820+
describe('image', () => {
821+
it('always shows file, url and cloud actions in the image dropdown', () => {
822+
const action = actions.image()
823+
const childIds = action.childActions?.map(({ id }) => id) || []
824+
825+
expect(childIds).toEqual(['image-upload', 'image-url', 'image-cloud'])
826+
})
827+
828+
it('provides "Insert from cloud" action for current resource', () => {
829+
const cloudState = createState()
830+
const cloudActions = useEditorActions(cloudState)
831+
const cloudChild = cloudActions.image().childActions?.find(({ id }) => id === 'image-cloud')
832+
833+
expect(cloudChild).toBeTruthy()
834+
})
835+
})
836+
817837
describe('menuEmoji', () => {
818838
it('uses menu component mode and is hidden from slash commands', () => {
819839
const action = actions.menuEmoji()

0 commit comments

Comments
 (0)