Skip to content
Open
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
34 changes: 34 additions & 0 deletions src/components/TextEditor/InsertAttachment.vue
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
/>
Comment on lines +3 to +9
Copy link

Copilot AI Jan 7, 2026

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.

Copilot uses AI. Check for mistakes.
</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()
})
}
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file input does not reset after files are selected, which prevents users from uploading the same file twice in succession. Consider resetting the input value after processing the files to allow re-uploading the same file.

Suggested change
}
}
// Reset the file input so the same file can be selected again
target.value = ''

Copilot uses AI. Check for mistakes.
}
</script>
4 changes: 4 additions & 0 deletions src/components/TextEditor/TextEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -204,6 +205,9 @@ onMounted(() => {
ImageGroup.configure({
uploadFunction: props.uploadFunction || defaultUploadFunction,
}),
AttachmentExtension.configure({
uploadFunction: props.uploadFunction || defaultUploadFunction,
}),
ImageViewerExtension,
VideoExtension.configure({
uploadFunction: props.uploadFunction || defaultUploadFunction,
Expand Down
1 change: 1 addition & 0 deletions src/components/TextEditor/TextEditorFixedMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default {
'FontColor',
'Separator',
'Image',
'Attach',
'Video',
'Iframe',
'Link',
Expand Down
6 changes: 6 additions & 0 deletions src/components/TextEditor/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
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
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cursor style is set to "cursor-pointer" on the entire attachment div, but the download icon only appears on hover. This creates a visual inconsistency where the pointer cursor appears even when the download icon is not visible, which might confuse users about what is clickable. Consider either making the download icon always visible or adjusting the cursor behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +35
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The attachment node lacks keyboard accessibility. Users cannot activate the download action using keyboard navigation (e.g., pressing Enter or Space). Consider adding a tabindex and keyboard event handlers, or using a button element instead of a div for the clickable area to ensure proper keyboard accessibility.

Copilot uses AI. Check for mistakes.
</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')
}
}
Comment thread
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
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AttachmentExtensionOptions interface lacks JSDoc comments, unlike similar extension options in the codebase (e.g., ImageExtensionOptions has detailed JSDoc comments for uploadFunction). Consider adding documentation comments to explain the purpose and default values of uploadFunction and HTMLAttributes properties for consistency and better developer experience.

Copilot uses AI. Check for mistakes.

declare module '@tiptap/core' {
interface Commands<ReturnType> {
attachment: {
setAttachment: (options: { src: string; filename?: string; size?: number }) => ReturnType
Comment on lines +16 to +17
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Commands interface for the attachment extension lacks JSDoc comments for the setAttachment and uploadAttachment methods, unlike the image extension which documents its commands. Consider adding documentation comments to describe the parameters and behavior of these commands for better developer experience and API clarity.

Suggested change
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 uses AI. Check for mistakes.
uploadAttachment: (file: File) => ReturnType
}
}
}

export const AttachmentExtension = NodeExtension.create<AttachmentExtensionOptions>({
name: 'attachment',

group: 'block',
draggable: true,
selectable: true,

addAttributes() {
return {
src: { default: null },
filename: { default: null },
size: { default: null },
contentType: { default: null },
Copy link

Copilot AI Jan 7, 2026

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 uses AI. Check for mistakes.
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' }),
]
},
Comment on lines +48 to +53
Copy link

Copilot AI Jan 7, 2026

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 uses AI. Check for mistakes.

addNodeView() {
return VueNodeViewRenderer(AttachmentNodeView)
},

addOptions() {
return {
uploadFunction: null,
HTMLAttributes: {},
}
},

addCommands() {
return {
setAttachment:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
})
},
Comment on lines +68 to +75
Copy link

Copilot AI Jan 7, 2026

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 uses AI. Check for mistakes.

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
})
Comment on lines +77 to +92
Copy link

Copilot AI Jan 7, 2026

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 uses AI. Check for mistakes.

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
}
Comment on lines +101 to +110
Copy link

Copilot AI Jan 7, 2026

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.

Suggested change
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 uses AI. Check for mistakes.
})
view.dispatch(transaction)
}).catch((error) => {
console.error("Failed to upload attachment", error)
Copy link

Copilot AI Jan 7, 2026

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.

Suggested change
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 uses AI. Check for mistakes.
})

return true
Copy link

Copilot AI Jan 7, 2026

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.

Copilot uses AI. Check for mistakes.
},
}
},
})
1 change: 1 addition & 0 deletions src/components/TextEditor/extensions/attachment/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AttachmentExtension } from './attachment-extension'
14 changes: 14 additions & 0 deletions src/components/TextEditor/icons/attachment.vue
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>
6 changes: 6 additions & 0 deletions src/env.d.ts
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
}