UNPKG

claritykit-svelte

Version:

A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility

443 lines (442 loc) 17.4 kB
import { Node, mergeAttributes } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; export const FileAttachmentExtension = Node.create({ name: 'fileAttachment', addOptions() { return { HTMLAttributes: { class: 'file-attachment', }, allowPreview: true, allowDownload: true, maxPreviewSize: 50 * 1024 * 1024, // 50MB previewableTypes: [ 'application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'application/json', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', ], }; }, group: 'block', atom: true, addAttributes() { return { fileId: { default: null, parseHTML: element => element.getAttribute('data-file-id'), renderHTML: attributes => { if (!attributes.fileId) return {}; return { 'data-file-id': attributes.fileId }; }, }, fileName: { default: '', parseHTML: element => element.getAttribute('data-file-name'), renderHTML: attributes => { if (!attributes.fileName) return {}; return { 'data-file-name': attributes.fileName }; }, }, fileSize: { default: 0, parseHTML: element => { const size = element.getAttribute('data-file-size'); return size ? parseInt(size) : 0; }, renderHTML: attributes => { return { 'data-file-size': attributes.fileSize.toString() }; }, }, fileType: { default: '', parseHTML: element => element.getAttribute('data-file-type'), renderHTML: attributes => { if (!attributes.fileType) return {}; return { 'data-file-type': attributes.fileType }; }, }, fileUrl: { default: '', parseHTML: element => element.getAttribute('data-file-url'), renderHTML: attributes => { if (!attributes.fileUrl) return {}; return { 'data-file-url': attributes.fileUrl }; }, }, downloadUrl: { default: null, parseHTML: element => element.getAttribute('data-download-url'), renderHTML: attributes => { if (!attributes.downloadUrl) return {}; return { 'data-download-url': attributes.downloadUrl }; }, }, previewUrl: { default: null, parseHTML: element => element.getAttribute('data-preview-url'), renderHTML: attributes => { if (!attributes.previewUrl) return {}; return { 'data-preview-url': attributes.previewUrl }; }, }, thumbnail: { default: null, parseHTML: element => element.getAttribute('data-thumbnail'), renderHTML: attributes => { if (!attributes.thumbnail) return {}; return { 'data-thumbnail': attributes.thumbnail }; }, }, uploadedBy: { default: null, parseHTML: element => { const data = element.getAttribute('data-uploaded-by'); return data ? JSON.parse(data) : null; }, renderHTML: attributes => { if (!attributes.uploadedBy) return {}; return { 'data-uploaded-by': JSON.stringify(attributes.uploadedBy) }; }, }, uploadedAt: { default: '', parseHTML: element => element.getAttribute('data-uploaded-at'), renderHTML: attributes => { if (!attributes.uploadedAt) return {}; return { 'data-uploaded-at': attributes.uploadedAt }; }, }, }; }, parseHTML() { return [ { tag: 'div.file-attachment', }, ]; }, renderHTML({ node, HTMLAttributes }) { const { fileId, fileName, fileSize, fileType, fileUrl, downloadUrl, previewUrl, thumbnail, uploadedBy, uploadedAt, } = node.attrs; const isPreviewable = this.options.previewableTypes.includes(fileType) && fileSize <= this.options.maxPreviewSize; const fileIcon = this.getFileIcon(fileType); const formattedSize = this.formatFileSize(fileSize); const formattedDate = uploadedAt ? new Date(uploadedAt).toLocaleDateString() : ''; return [ 'div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-file-id': fileId, 'data-file-name': fileName, 'data-file-size': fileSize.toString(), 'data-file-type': fileType, 'data-file-url': fileUrl, 'data-download-url': downloadUrl, 'data-preview-url': previewUrl, 'data-thumbnail': thumbnail, 'data-uploaded-by': uploadedBy ? JSON.stringify(uploadedBy) : '', 'data-uploaded-at': uploadedAt, }), [ 'div', { class: 'file-attachment-content' }, [ 'div', { class: 'file-icon-container' }, thumbnail ? [ 'img', { src: thumbnail, alt: fileName, class: 'file-thumbnail', }, ] : [ 'div', { class: 'file-icon' }, fileIcon, ], ], [ 'div', { class: 'file-info' }, [ 'div', { class: 'file-name' }, fileName, ], [ 'div', { class: 'file-meta' }, [ 'span', { class: 'file-size' }, formattedSize, ], [ 'span', { class: 'file-type' }, this.getFileTypeLabel(fileType), ], formattedDate && [ 'span', { class: 'file-date' }, formattedDate, ], ].filter(Boolean), uploadedBy && [ 'div', { class: 'file-uploader' }, 'Uploaded by ', [ 'span', { class: 'uploader-name' }, uploadedBy.name, ], ], ], [ 'div', { class: 'file-actions' }, isPreviewable && this.options.allowPreview && [ 'button', { class: 'file-action-btn preview-btn', 'data-action': 'preview', title: 'Preview file', }, '👁️ Preview', ], this.options.allowDownload && [ 'button', { class: 'file-action-btn download-btn', 'data-action': 'download', title: 'Download file', }, '📥 Download', ], [ 'button', { class: 'file-action-btn delete-btn', 'data-action': 'delete', title: 'Remove attachment', }, '🗑️', ], ].filter(Boolean), ], ]; }, addCommands() { return { insertFileAttachment: (file) => ({ commands }) => { return commands.insertContent({ type: this.name, attrs: { fileId: file.id, fileName: file.name, fileSize: file.size, fileType: file.type, fileUrl: file.url, downloadUrl: file.downloadUrl, previewUrl: file.previewUrl, thumbnail: file.thumbnail, uploadedBy: file.uploadedBy, uploadedAt: file.uploadedAt, }, }); }, updateFileMetadata: (fileId, metadata) => ({ tr, state }) => { const { doc } = state; let updated = false; doc.descendants((node, pos) => { if (node.type === this.type && node.attrs.fileId === fileId) { const newAttrs = { ...node.attrs, ...metadata }; tr.setNodeMarkup(pos, undefined, newAttrs); updated = true; return false; } }); return updated; }, removeFileAttachment: (fileId) => ({ tr, state }) => { const { doc } = state; let removed = false; doc.descendants((node, pos) => { if (node.type === this.type && node.attrs.fileId === fileId) { tr.delete(pos, pos + node.nodeSize); removed = true; return false; } }); return removed; }, }; }, addProseMirrorPlugins() { return [ new Plugin({ key: new PluginKey('fileAttachment'), props: { handleClick: (view, pos, event) => { const target = event.target; const fileAttachment = target.closest('.file-attachment'); if (!fileAttachment) return false; const fileId = fileAttachment.getAttribute('data-file-id'); const action = target.getAttribute('data-action'); if (action) { event.preventDefault(); event.stopPropagation(); switch (action) { case 'preview': this.options.onFileClick?.(fileId, 'preview'); this.showFilePreview(fileAttachment); break; case 'download': this.options.onFileClick?.(fileId, 'download'); this.downloadFile(fileAttachment); break; case 'delete': if (confirm('Are you sure you want to remove this attachment?')) { this.options.onFileDelete?.(fileId); this.editor.commands.removeFileAttachment(fileId); } break; } return true; } return false; }, }, }), ]; }, // Helper methods getFileIcon(type) { if (type.startsWith('image/')) return '🖼️'; if (type.startsWith('video/')) return '🎥'; if (type.startsWith('audio/')) return '🎵'; if (type.includes('pdf')) return '📄'; if (type.includes('document') || type.includes('word')) return '📝'; if (type.includes('spreadsheet') || type.includes('excel')) return '📊'; if (type.includes('presentation') || type.includes('powerpoint')) return '📽️'; if (type.includes('text')) return '📃'; if (type.includes('zip') || type.includes('archive')) return '🗜️'; if (type.includes('json') || type.includes('xml')) return '📋'; return '📎'; }, getFileTypeLabel(type) { const typeMap = { 'application/pdf': 'PDF', 'text/plain': 'Text', 'text/markdown': 'Markdown', 'text/csv': 'CSV', 'application/json': 'JSON', 'application/xml': 'XML', 'image/jpeg': 'JPEG', 'image/png': 'PNG', 'image/gif': 'GIF', 'image/webp': 'WebP', 'image/svg+xml': 'SVG', 'video/mp4': 'MP4', 'video/webm': 'WebM', 'audio/mp3': 'MP3', 'audio/wav': 'WAV', 'audio/ogg': 'OGG', }; return typeMap[type] || type.split('/')[1]?.toUpperCase() || 'File'; }, formatFileSize(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }, showFilePreview(element) { const previewUrl = element.getAttribute('data-preview-url'); const fileUrl = element.getAttribute('data-file-url'); const fileName = element.getAttribute('data-file-name'); const fileType = element.getAttribute('data-file-type'); const url = previewUrl || fileUrl; if (!url) return; // Create preview modal const modal = document.createElement('div'); modal.className = 'file-preview-modal'; modal.innerHTML = ` <div class="file-preview-backdrop" onclick="this.parentElement.remove()"></div> <div class="file-preview-content"> <div class="file-preview-header"> <h3>${fileName}</h3> <button class="file-preview-close" onclick="this.closest('.file-preview-modal').remove()">✕</button> </div> <div class="file-preview-body"> ${this.generatePreviewContent(url, fileType)} </div> </div> `; document.body.appendChild(modal); }, generatePreviewContent(url, type) { if (type.startsWith('image/')) { return `<img src="${url}" alt="Preview" style="max-width: 100%; max-height: 80vh;" />`; } else if (type === 'application/pdf') { return `<iframe src="${url}" style="width: 100%; height: 80vh;" frameborder="0"></iframe>`; } else if (type.startsWith('text/') || type.includes('json') || type.includes('xml')) { return `<iframe src="${url}" style="width: 100%; height: 80vh;" frameborder="0"></iframe>`; } else { return `<p>Preview not available for this file type. <a href="${url}" target="_blank">Open in new tab</a></p>`; } }, downloadFile(element) { const downloadUrl = element.getAttribute('data-download-url'); const fileUrl = element.getAttribute('data-file-url'); const fileName = element.getAttribute('data-file-name'); const url = downloadUrl || fileUrl; if (!url) return; // Create temporary download link const link = document.createElement('a'); link.href = url; link.download = fileName || 'download'; link.style.display = 'none'; document.body.appendChild(link); link.click(); document.body.removeChild(link); }, }); export default FileAttachmentExtension;