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
JavaScript
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;