-
Notifications
You must be signed in to change notification settings - Fork 299
feat: Add attachment support to TextEditor (fixes #527) #530
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,34 @@ | ||||||||||
| <template> | ||||||||||
| <slot v-bind="{ onClick: openFileSelector }"></slot> | ||||||||||
| <input | ||||||||||
| ref="fileInput" | ||||||||||
| type="file" | ||||||||||
| class="hidden" | ||||||||||
| @change="onFileSelect" | ||||||||||
| multiple | ||||||||||
| /> | ||||||||||
| </template> | ||||||||||
| <script setup lang="ts"> | ||||||||||
| import { ref, useTemplateRef } from 'vue' | ||||||||||
| import type { Editor } from '@tiptap/vue-3' | ||||||||||
|
|
||||||||||
| const props = defineProps<{ | ||||||||||
| editor: Editor | ||||||||||
| }>() | ||||||||||
|
|
||||||||||
| const fileInput = useTemplateRef<HTMLInputElement>('fileInput') | ||||||||||
|
|
||||||||||
| function openFileSelector() { | ||||||||||
| fileInput.value?.click() | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function onFileSelect(e: Event) { | ||||||||||
| const target = e.target as HTMLInputElement | ||||||||||
| const files = target.files | ||||||||||
| if (files && files.length > 0) { | ||||||||||
| Array.from(files).forEach(file => { | ||||||||||
| props.editor.chain().focus().uploadAttachment(file).run() | ||||||||||
| }) | ||||||||||
| } | ||||||||||
|
||||||||||
| } | |
| } | |
| // Reset the file input so the same file can be selected again | |
| target.value = '' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,6 +41,7 @@ export default { | |
| 'FontColor', | ||
| 'Separator', | ||
| 'Image', | ||
| 'Attach', | ||
| 'Video', | ||
| 'Iframe', | ||
| 'Link', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| <template> | ||
| <node-view-wrapper class="attachment-node-view my-2"> | ||
| <div | ||
| class="flex items-center gap-3 p-3 border rounded-lg bg-surface-gray-1 hover:bg-surface-gray-2 transition-colors cursor-pointer group" | ||
| @click="download" | ||
| > | ||
| <div class="p-2 bg-white rounded-md border text-gray-500"> | ||
| <!-- Paperclip icon --> | ||
| <svg | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| viewBox="0 0 24 24" | ||
| width="20" | ||
| height="20" | ||
| > | ||
| <path fill="none" d="M0 0h24v24H0z" /> | ||
| <path | ||
| d="M14.828 7.757l-5.656 5.657a1 1 0 0 1-1.414-1.414l5.656-5.657A3 3 0 1 1 17.656 10.586l-5.656 5.656A5 5 0 1 1 4.929 9.172l5.656-5.657 1.414 1.414-5.656 5.657a3 3 0 1 0 4.242 4.242l5.656-5.656a1 1 0 0 0-1.414-1.414z" | ||
| fill="currentColor" | ||
| /> | ||
| </svg> | ||
| </div> | ||
| <div class="flex-1 overflow-hidden"> | ||
| <div class="text-sm font-medium text-gray-900 truncate"> | ||
| {{ node.attrs.filename || 'Uploading...' }} | ||
| </div> | ||
| <div class="text-xs text-gray-500 flex items-center gap-2"> | ||
| <span v-if="node.attrs.size">{{ formatSize(node.attrs.size) }}</span> | ||
| <span v-if="node.attrs.uploadId" class="text-xs text-orange-500">Uploading...</span> | ||
| </div> | ||
| </div> | ||
| <div class="opacity-0 group-hover:opacity-100 transition-opacity"> | ||
| <!-- Download Icon --> | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-gray-500"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg> | ||
| </div> | ||
| </div> | ||
|
Comment on lines
+3
to
+35
|
||
| </node-view-wrapper> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3' | ||
|
|
||
| const props = defineProps(nodeViewProps) | ||
|
|
||
| function formatSize(bytes: number) { | ||
| if (bytes === 0) return '0 B' | ||
| const k = 1024 | ||
| const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] | ||
| const i = Math.floor(Math.log(bytes) / Math.log(k)) | ||
| return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] | ||
| } | ||
|
|
||
| function download() { | ||
| if (props.node.attrs.src) { | ||
| window.open(props.node.attrs.src, '_blank') | ||
| } | ||
| } | ||
|
itshivams marked this conversation as resolved.
|
||
| </script> | ||
|
|
||
| <style scoped> | ||
| .attachment-node-view { | ||
| user-select: none; | ||
| } | ||
| </style> | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<UploadedFile>) | null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| HTMLAttributes: Record<string, any> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+9
to
+12
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| declare module '@tiptap/core' { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface Commands<ReturnType> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| attachment: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setAttachment: (options: { src: string; filename?: string; size?: number }) => ReturnType | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+16
to
+17
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| attachment: { | |
| setAttachment: (options: { src: string; filename?: string; size?: number }) => ReturnType | |
| attachment: { | |
| /** | |
| * Insert an attachment node with the given attributes. | |
| * | |
| * @param options - Attributes for the attachment node. | |
| * @param options.src - URL of the uploaded file. | |
| * @param options.filename - Optional display filename for the attachment. | |
| * @param options.size - Optional size of the file in bytes. | |
| * @returns A command chainable `ReturnType`. | |
| */ | |
| setAttachment: (options: { src: string; filename?: string; size?: number }) => ReturnType | |
| /** | |
| * Upload a file and insert/update an attachment node in the document. | |
| * | |
| * Inserts a temporary attachment node associated with the file and, | |
| * once the configured `uploadFunction` resolves, updates the node | |
| * with the final URL and metadata. | |
| * | |
| * @param file - The file to upload as an attachment. | |
| * @returns A command chainable `ReturnType`. | |
| */ |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The contentType attribute is defined and stored in the attachment node but is never used anywhere in the component or extension logic. Consider either using this attribute (e.g., to display a type-specific icon or filter attachments) or removing it if it's not needed to keep the code clean and maintainable.
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The renderHTML method sets the href attribute to HTMLAttributes.src without validation. If src contains a javascript: or data: URL, this could introduce an XSS vulnerability. Consider validating or sanitizing the src attribute to ensure it only contains safe protocols (http:, https:) before rendering.
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The setAttachment command does not validate that the required src parameter is provided. If called with an empty or null src, it will create an attachment node that cannot be opened or downloaded. Consider adding validation to ensure src is a non-empty string before inserting the attachment.
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The file size is not validated before upload. Large files could cause performance issues or overwhelm the server. Consider adding a file size limit check similar to other file upload implementations, or documenting that size validation should be handled by the uploadFunction.
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The uploadAttachment command uses descendants to find and update the attachment node after upload completes. However, it doesn't stop iteration after finding the first match (despite returning false). If multiple attachments with the same uploadId somehow exist, only the first one will be updated. While this is unlikely, it would be more explicit and efficient to use a flag or break mechanism to ensure only one node is processed.
| 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 | |
| } | |
| let attachmentUpdated = false | |
| view.state.doc.descendants((node, pos) => { | |
| if (attachmentUpdated) { | |
| // A matching attachment has already been updated; skip further processing. | |
| return false | |
| } | |
| 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, | |
| }) | |
| attachmentUpdated = true | |
| return false | |
| } | |
| return |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The uploadAttachment command does not handle upload failures gracefully. When an upload fails, the attachment node remains in the editor with no visual indication to the user that it failed, only a console error. Consider removing the failed attachment node from the editor or adding a visible error state to the attachment node to inform users of the failure.
| console.error("Failed to upload attachment", error) | |
| console.error('Failed to upload attachment', error) | |
| const transaction = view.state.tr | |
| let nodeFound = false | |
| view.state.doc.descendants((node, pos) => { | |
| if (node.type.name === 'attachment' && node.attrs.uploadId === uploadId) { | |
| transaction.delete(pos, pos + node.nodeSize) | |
| nodeFound = true | |
| return false | |
| } | |
| }) | |
| if (nodeFound) { | |
| view.dispatch(transaction) | |
| } |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The uploadAttachment command always returns true, even when uploadFunction is not provided. This can mislead calling code into thinking the upload succeeded. Consider returning false when uploadFunction is not provided, to align with the early return pattern already in place.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { AttachmentExtension } from './attachment-extension' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| <template> | ||
| <svg | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| viewBox="0 0 24 24" | ||
| width="16" | ||
| height="16" | ||
| > | ||
| <path fill="none" d="M0 0h24v24H0z" /> | ||
| <path | ||
| d="M14.828 7.757l-5.656 5.657a1 1 0 0 1-1.414-1.414l5.656-5.657A3 3 0 1 1 17.656 10.586l-5.656 5.656A5 5 0 1 1 4.929 9.172l5.656-5.657 1.414 1.414-5.656 5.657a3 3 0 1 0 4.242 4.242l5.656-5.656a1 1 0 0 0-1.414-1.414z" | ||
| fill="currentColor" | ||
| /> | ||
| </svg> | ||
| </template> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,8 @@ | ||
| /// <reference types="vite/client" /> | ||
| /// <reference types="@histoire/plugin-vue/components" /> | ||
|
|
||
| declare module '*.vue' { | ||
| import type { DefineComponent } from 'vue' | ||
| const component: DefineComponent<{}, {}, any> | ||
| export default component | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The InsertAttachment component accepts any file type without validation or restriction. Unlike the image extension which filters for image files, this could allow users to upload potentially dangerous file types. Consider adding a file type allowlist or validation, or at minimum documenting that the uploadFunction should handle validation.