Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/web-app-text-editor/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const placeholder = computed(() => {

const textEditor = useTextEditor({
contentType: unref(parsedContentType),
currentResource: toRef(() => resource),
modelValue: toRef(() => currentContent),
readonly: isReadOnly,
placeholder: unref(placeholder),
Expand Down
74 changes: 71 additions & 3 deletions packages/web-pkg/src/editor/composables/useEditorActions.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { markRaw, ref, unref } from 'vue'
import { computed, markRaw, ref, unref } from 'vue'
import type { Component } from 'vue'
import type { Editor, Range } from '@tiptap/core'
import type {} from '@tiptap/extension-text-align'
import { useGettext } from 'vue3-gettext'
import { storeToRefs } from 'pinia'
import type { Resource } from '@opencloud-eu/web-client'
import { OcEmojiPicker } from '@opencloud-eu/design-system/components'
import { useModals, useThemeStore } from '../../composables'
import { useClientService } from '../../composables/clientService'
import { useGetMatchingSpace } from '../../composables/spaces'
import { useFolderLink } from '../../composables/folderLink'
import FilePickerModal from '../../components/Modals/FilePickerModal.vue'
import { arrayBufferToDataUrl } from '../../helpers'
import { TextEditorState } from '../types'
import { requestLinkPanel } from '../helpers/link'
import TextEditorSearchAndReplacePanel from '../components/TextEditorSearchAndReplacePanel.vue'
Expand Down Expand Up @@ -64,6 +70,12 @@ export function useEditorActions(state: TextEditorState) {
const { dispatchModal } = useModals()
const themeStore = useThemeStore()
const { currentTheme } = storeToRefs(themeStore)
const clientService = useClientService()
const { getMatchingSpace } = useGetMatchingSpace()
const { getParentFolderLink } = useFolderLink()
const currentResource = computed<Resource | null>(() => {
return unref(state.currentResource) ?? null
})

//Search
const searchSearchTerm = ref('')
Expand Down Expand Up @@ -544,6 +556,47 @@ export function useEditorActions(state: TextEditorState) {
})
}

const insertImageFromCloudResource = async (editor: Editor, resource: Resource) => {
const space = getMatchingSpace(resource)
const response = await clientService.webdav.getFileContents(
space,
{ path: resource.path },
{ responseType: 'arraybuffer' }
)
const body = response?.body as ArrayBuffer | undefined
if (!body) {
return
}
const mimeType = resource.mimeType || 'application/octet-stream'
const dataUrl = await arrayBufferToDataUrl(body, mimeType)
editor.chain().focus().setImage({ src: dataUrl }).run()
}

const openCloudImagePicker = (editor: Editor) => {
const resource = unref(currentResource)
if (!resource) {
return
}

dispatchModal({
elementClass: 'file-picker-modal',
title: $gettext('Insert image from cloud'),
customComponent: markRaw(FilePickerModal),
hideActions: true,
customComponentAttrs: () => ({
allowedFileTypes: ['image/png', 'image/gif', 'image/jpeg', 'image/svg'],
parentFolderLink: getParentFolderLink(resource),
callbackFn: async ({ resource }: { resource: Resource }) => {
if (!resource.mimeType?.startsWith('image/')) {
return
}
await insertImageFromCloudResource(editor, resource)
}
}),
focusTrapInitial: false
})
}

const imageUrl = (): EditorAction => ({
id: 'image-url',
title: $gettext('Image from URL'),
Expand Down Expand Up @@ -575,13 +628,28 @@ export function useEditorActions(state: TextEditorState) {
isActive: () => false
})

const imageCloud = (): EditorAction => ({
id: 'image-cloud',
title: $gettext('Insert from cloud'),
description: $gettext('Insert an image from your cloud files'),
icon: 'cloud-line',
keywords: ['image', 'picture', 'cloud'],
showInSlashCommands: false,
toolbarAction: (editor) => openCloudImagePicker(editor),
slashCommandAction: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run()
openCloudImagePicker(editor)
},
isActive: () => false
})

const image = (): EditorAction => ({
id: 'image',
title: $gettext('Insert image'),
icon: 'image-line',
keywords: ['image', 'picture', 'upload', 'url'],
keywords: ['image', 'picture', 'upload', 'url', 'cloud'],
showInSlashCommands: false,
childActions: [imageUpload(), imageUrl()],
childActions: [imageUpload(), imageUrl(), imageCloud()],
isActive: () => false
})

Expand Down
4 changes: 3 additions & 1 deletion packages/web-pkg/src/editor/composables/useTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEditor } from '@tiptap/vue-3'
import { Placeholder } from '@tiptap/extension-placeholder'
import type { ShallowRef } from 'vue'
import type { Editor } from '@tiptap/vue-3'
import type { Resource } from '@opencloud-eu/web-client'
import type {
TextEditorOptions,
TextEditorInstance,
Expand All @@ -18,7 +19,8 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance {
const state: TextEditorState = {
sourceMode: ref(false),
linkPanel: ref<TextEditorLinkPanelRequest | null>(null),
editorZoom: ref(100)
editorZoom: ref(100),
currentResource: options.currentResource ?? ref<Resource | null>(null)
}

const contentType = ref(options.contentType)
Expand Down
3 changes: 3 additions & 0 deletions packages/web-pkg/src/editor/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { ShallowRef, Ref, ComputedRef } from 'vue'
import type { Range } from '@tiptap/core'
import type { Editor } from '@tiptap/vue-3'
import type { Resource } from '@opencloud-eu/web-client'
import type { EditorActionGroup } from './composables'

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

export interface TextEditorOptions {
contentType: ContentType
modelValue?: Ref<string>
currentResource?: Ref<Resource>
readonly?: boolean
slashCommands?: boolean
placeholder?: string
Expand All @@ -31,6 +33,7 @@ export interface TextEditorState {
sourceMode: Ref<boolean>
linkPanel: Ref<TextEditorLinkPanelRequest | null>
editorZoom: Ref<number>
currentResource?: Ref<Resource | null>
}

export interface TextEditorInstance {
Expand Down
9 changes: 9 additions & 0 deletions packages/web-pkg/src/helpers/binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,12 @@ export const blobToArrayBuffer: (blob: Blob) => Promise<string | ArrayBuffer> =
export const canvasToBlob = (canvas: HTMLCanvasElement): Promise<Blob> => {
return new Promise((resolve) => canvas.toBlob(resolve))
}

export const arrayBufferToDataUrl = (buffer: ArrayBuffer, mimeType: string): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => resolve(reader.result as string))
reader.addEventListener('error', () => reject(reader.error))
reader.readAsDataURL(new Blob([buffer], { type: mimeType }))
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { Range } from '@tiptap/core'
import { Editor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { mock } from 'vitest-mock-extended'
import { createMockEditor } from './helpers'

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

import { useEditorActions } from '../../../../src/editor/composables/useEditorActions'
import type { TextEditorLinkPanelRequest, TextEditorState } from '../../../../src/editor/types'
import type { Resource } from '@opencloud-eu/web-client'
import { createTestingPinia } from '@opencloud-eu/web-test-helpers'
import { useModals } from '../../../../src/composables/piniaStores'
import { createLinkExtension } from '../../../../src/editor/extensions/link'
Expand All @@ -19,7 +21,8 @@ function createState(): TextEditorState {
return {
sourceMode: ref(false),
linkPanel: ref<TextEditorLinkPanelRequest | null>(null),
editorZoom: ref(100)
editorZoom: ref(100),
currentResource: ref<Resource | null>(mock<Resource>({ id: 'resource', path: '/' }))
}
}

Expand Down Expand Up @@ -814,6 +817,23 @@ describe('useEditorActions', () => {
})
})

describe('image', () => {
it('always shows file, url and cloud actions in the image dropdown', () => {
const action = actions.image()
const childIds = action.childActions?.map(({ id }) => id) || []

expect(childIds).toEqual(['image-upload', 'image-url', 'image-cloud'])
})

it('provides "Insert from cloud" action for current resource', () => {
const cloudState = createState()
const cloudActions = useEditorActions(cloudState)
const cloudChild = cloudActions.image().childActions?.find(({ id }) => id === 'image-cloud')

expect(cloudChild).toBeTruthy()
})
})

describe('menuEmoji', () => {
it('uses menu component mode and is hidden from slash commands', () => {
const action = actions.menuEmoji()
Expand Down