diff --git a/src/components/TextEditor/InsertAttachment.vue b/src/components/TextEditor/InsertAttachment.vue new file mode 100644 index 000000000..9036d6e3f --- /dev/null +++ b/src/components/TextEditor/InsertAttachment.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/src/components/TextEditor/TextEditor.vue b/src/components/TextEditor/TextEditor.vue index 05ed1c9e9..27771e700 100644 --- a/src/components/TextEditor/TextEditor.vue +++ b/src/components/TextEditor/TextEditor.vue @@ -69,6 +69,7 @@ import { TagNode, TagExtension } from './extensions/tag/tag-extension' import { Heading } from './extensions/heading/heading' import { ImageGroup } from './extensions/image-group/image-group-extension' import { ExtendedCode, ExtendedCodeBlock } from './extensions/code-block' +import { AttachmentExtension } from './extensions/attachment' import { useFileUpload } from '../../utils/useFileUpload' import { TextEditorEmits, TextEditorProps } from './types' @@ -204,6 +205,9 @@ onMounted(() => { ImageGroup.configure({ uploadFunction: props.uploadFunction || defaultUploadFunction, }), + AttachmentExtension.configure({ + uploadFunction: props.uploadFunction || defaultUploadFunction, + }), ImageViewerExtension, VideoExtension.configure({ uploadFunction: props.uploadFunction || defaultUploadFunction, diff --git a/src/components/TextEditor/TextEditorFixedMenu.vue b/src/components/TextEditor/TextEditorFixedMenu.vue index 91923e55c..9c7c6de6c 100644 --- a/src/components/TextEditor/TextEditorFixedMenu.vue +++ b/src/components/TextEditor/TextEditorFixedMenu.vue @@ -41,6 +41,7 @@ export default { 'FontColor', 'Separator', 'Image', + 'Attach', 'Video', 'Iframe', 'Link', diff --git a/src/components/TextEditor/commands.js b/src/components/TextEditor/commands.js index 4c05e6e58..dfad474c5 100644 --- a/src/components/TextEditor/commands.js +++ b/src/components/TextEditor/commands.js @@ -180,6 +180,12 @@ export default { isActive: (editor) => false, component: defineAsyncComponent(() => import('./InsertImage.vue')), }, + Attach: { + label: 'Attach', + icon: defineAsyncComponent(() => import('./icons/attachment.vue')), + isActive: (editor) => false, + component: defineAsyncComponent(() => import('./InsertAttachment.vue')), + }, Video: { label: 'Video', icon: Video, diff --git a/src/components/TextEditor/extensions/attachment/AttachmentNodeView.vue b/src/components/TextEditor/extensions/attachment/AttachmentNodeView.vue new file mode 100644 index 000000000..bdf84c984 --- /dev/null +++ b/src/components/TextEditor/extensions/attachment/AttachmentNodeView.vue @@ -0,0 +1,63 @@ + + + + + + + + + + + + + {{ node.attrs.filename || 'Uploading...' }} + + + {{ formatSize(node.attrs.size) }} + Uploading... + + + + + + + + + + + + + diff --git a/src/components/TextEditor/extensions/attachment/attachment-extension.ts b/src/components/TextEditor/extensions/attachment/attachment-extension.ts new file mode 100644 index 000000000..2304ab67c --- /dev/null +++ b/src/components/TextEditor/extensions/attachment/attachment-extension.ts @@ -0,0 +1,121 @@ +import { + Node as NodeExtension, + mergeAttributes, +} from '@tiptap/core' +import { VueNodeViewRenderer } from '@tiptap/vue-3' +import AttachmentNodeView from './AttachmentNodeView.vue' +import { UploadedFile } from '../../../../utils/useFileUpload' + +export interface AttachmentExtensionOptions { + uploadFunction: ((file: File) => Promise) | null + HTMLAttributes: Record +} + +declare module '@tiptap/core' { + interface Commands { + attachment: { + setAttachment: (options: { src: string; filename?: string; size?: number }) => ReturnType + uploadAttachment: (file: File) => ReturnType + } + } +} + +export const AttachmentExtension = NodeExtension.create({ + name: 'attachment', + + group: 'block', + draggable: true, + selectable: true, + + addAttributes() { + return { + src: { default: null }, + filename: { default: null }, + size: { default: null }, + contentType: { default: null }, + uploadId: { default: null }, + } + }, + + parseHTML() { + return [ + { + tag: 'a[data-type="attachment"]', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'a', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': 'attachment', href: HTMLAttributes.src, target: '_blank' }), + ] + }, + + addNodeView() { + return VueNodeViewRenderer(AttachmentNodeView) + }, + + addOptions() { + return { + uploadFunction: null, + HTMLAttributes: {}, + } + }, + + addCommands() { + return { + setAttachment: + (options) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: options, + }) + }, + + uploadAttachment: + (file: File) => + ({ editor, view }) => { + if (!this.options.uploadFunction) { + console.error('uploadFunction option is not provided') + return false + } + + const uploadId = `upload-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` + + const node = view.state.schema.nodes.attachment.create({ + uploadId, + filename: file.name, + size: file.size, + contentType: file.type + }) + + const tr = view.state.tr + const insertPos = view.state.selection.from + tr.insert(insertPos, node) + view.dispatch(tr) + + this.options.uploadFunction(file).then((uploadedFile) => { + const transaction = view.state.tr + view.state.doc.descendants((node, pos) => { + if (node.type.name === 'attachment' && node.attrs.uploadId === uploadId) { + transaction.setNodeMarkup(pos, undefined, { + ...node.attrs, + src: uploadedFile.file_url, + filename: uploadedFile.file_name || node.attrs.filename, + uploadId: null + }) + return false + } + }) + view.dispatch(transaction) + }).catch((error) => { + console.error("Failed to upload attachment", error) + }) + + return true + }, + } + }, +}) diff --git a/src/components/TextEditor/extensions/attachment/index.ts b/src/components/TextEditor/extensions/attachment/index.ts new file mode 100644 index 000000000..8be2f60ab --- /dev/null +++ b/src/components/TextEditor/extensions/attachment/index.ts @@ -0,0 +1 @@ +export { AttachmentExtension } from './attachment-extension' diff --git a/src/components/TextEditor/icons/attachment.vue b/src/components/TextEditor/icons/attachment.vue new file mode 100644 index 000000000..751f75e08 --- /dev/null +++ b/src/components/TextEditor/icons/attachment.vue @@ -0,0 +1,14 @@ + + + + + + diff --git a/src/env.d.ts b/src/env.d.ts index 1142a7d78..664f4997f 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,2 +1,8 @@ /// /// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +}