import { Node, mergeAttributes } from '@tiptap/core'; export interface FileAttachmentOptions { HTMLAttributes: Record; } export const FileAttachment = Node.create({ name: 'fileAttachment', group: 'block', selectable: true, draggable: true, addOptions() { return { HTMLAttributes: { class: 'file-attachment-node w-full max-w-sm rounded-lg border border-border bg-muted/30 hover:bg-muted/50 transition-colors my-4 overflow-hidden relative group cursor-pointer shadow-sm', }, }; }, addAttributes() { return { src: { default: null, parseHTML: element => element.getAttribute('data-src'), renderHTML: attributes => { if (!attributes.src) return {}; return { 'data-src': attributes.src }; }, }, filename: { default: null, parseHTML: element => element.getAttribute('data-filename'), renderHTML: attributes => { if (!attributes.filename) return {}; return { 'data-filename': attributes.filename }; }, }, filesize: { default: null, parseHTML: element => element.getAttribute('data-filesize'), renderHTML: attributes => { if (!attributes.filesize) return {}; return { 'data-filesize': attributes.filesize }; }, } }; }, parseHTML() { return [ { tag: 'div[data-type="file-attachment"]', }, { tag: 'a.file-attachment-link', } ]; }, renderHTML({ HTMLAttributes }) { const filename = HTMLAttributes.filename || ''; const isPdf = filename.toLowerCase().endsWith('.pdf'); if (isPdf) { return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': 'file-attachment', class: 'file-attachment-wrapper relative w-full my-4 rounded-xl overflow-hidden border border-border flex min-h-[500px] bg-muted/20 group' }), ['iframe', { src: HTMLAttributes.src, class: 'w-full h-full border-0 absolute inset-0' }] ]; } return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': 'file-attachment', }), ['a', { href: HTMLAttributes.src, target: '_blank', rel: 'noopener noreferrer', class: 'file-attachment-link flex items-center p-3 gap-3 w-full h-full' }, ['div', { class: 'flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary' }, // SVG for FileText ['svg', { xmlns: 'http://www.w3.org/2000/svg', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, ['path', { d: 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' }], ['path', { d: 'M14 2v4a2 2 0 0 0 2 2h4' }], ['path', { d: 'M10 9H8' }], ['path', { d: 'M16 13H8' }], ['path', { d: 'M16 17H8' }] ] ], ['div', { class: 'flex flex-col overflow-hidden w-full text-left' }, ['span', { class: 'text-sm font-medium truncate w-full text-foreground' }, HTMLAttributes.filename || 'Attachment'], ['span', { class: 'text-xs text-muted-foreground mt-0.5' }, 'View File'] ] ] ]; }, addKeyboardShortcuts() { return { Backspace: () => { const { selection } = this.editor.state; if (!selection.empty) { const selectedNode = this.editor.state.doc.nodeAt(selection.from); if (selectedNode?.type.name === this.name) { return true; // Prevent default deletion } } return false; }, Delete: () => { const { selection } = this.editor.state; if (!selection.empty) { const selectedNode = this.editor.state.doc.nodeAt(selection.from); if (selectedNode?.type.name === this.name) { return true; // Prevent default deletion } } return false; }, }; }, addNodeView() { return ({ node, getPos, editor }) => { const filename = node.attrs.filename || ''; const isPdf = filename.toLowerCase().endsWith('.pdf'); const isTxt = filename.toLowerCase().endsWith('.txt'); const isFramable = isPdf || isTxt; const wrapper = document.createElement('div'); wrapper.setAttribute('data-type', 'file-attachment'); wrapper.setAttribute('data-src', node.attrs.src); wrapper.setAttribute('data-filename', node.attrs.filename); wrapper.setAttribute('data-filesize', node.attrs.filesize); if (isFramable) { // Framed mode matching FileViewer wrapper.classList.add('file-viewer-wrapper', 'relative', 'w-full', 'my-4', 'rounded-xl', 'overflow-hidden', 'border', 'border-border', 'flex', 'bg-muted/10', 'group'); wrapper.style.minHeight = '500px'; const iframe = document.createElement('iframe'); iframe.src = node.attrs.src; iframe.classList.add('w-full', 'h-full', 'border-0', 'absolute', 'inset-0'); wrapper.appendChild(iframe); } else { // Card mode wrapper.classList.add('file-attachment-node', 'w-full', 'max-w-sm', 'rounded-lg', 'border', 'border-border', 'bg-muted/30', 'hover:bg-muted/50', 'transition-colors', 'my-4', 'overflow-hidden', 'relative', 'group', 'cursor-pointer', 'shadow-sm'); const anchor = document.createElement('a'); anchor.href = node.attrs.src || '#'; anchor.target = '_blank'; anchor.rel = 'noopener noreferrer'; anchor.classList.add('flex', 'items-center', 'p-3', 'gap-3', 'w-full', 'h-full', 'no-underline'); const iconContainer = document.createElement('div'); iconContainer.classList.add('flex-shrink-0', 'flex', 'items-center', 'justify-center', 'w-10', 'h-10', 'rounded-md', 'bg-primary/10', 'text-primary'); iconContainer.innerHTML = ''; const textContainer = document.createElement('div'); textContainer.classList.add('flex', 'flex-col', 'overflow-hidden', 'w-full', 'text-left'); const filenameSpan = document.createElement('span'); filenameSpan.classList.add('text-sm', 'font-medium', 'truncate', 'w-full', 'text-foreground'); filenameSpan.innerText = node.attrs.filename || 'Attachment'; const subTextSpan = document.createElement('span'); subTextSpan.classList.add('text-xs', 'text-muted-foreground', 'mt-0.5'); if (node.attrs.filesize) { const mb = (parseInt(node.attrs.filesize) / (1024 * 1024)).toFixed(2); subTextSpan.innerText = `${mb} MB • View File`; } else { subTextSpan.innerText = `View File`; } textContainer.appendChild(filenameSpan); textContainer.appendChild(subTextSpan); anchor.appendChild(iconContainer); anchor.appendChild(textContainer); wrapper.appendChild(anchor); } // Delete button overlay const overlay = document.createElement('div'); overlay.classList.add('absolute', 'top-1.5', 'right-1.5', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]:group-hover:flex', 'bg-background/90', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10'); const deleteBtn = document.createElement('button'); deleteBtn.innerHTML = ''; deleteBtn.classList.add('p-1', 'cursor-pointer'); deleteBtn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); if (typeof getPos === 'function') { const pos = getPos(); if (typeof pos === 'number') { editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize }); } } }; overlay.appendChild(deleteBtn); wrapper.appendChild(overlay); return { dom: wrapper, stopEvent: (e) => { // Stop event propagation if click is exactly on delete button, to prevent anchor following if (overlay.contains(e.target as unknown as globalThis.Node)) { return true; } return false; }, ignoreMutation: () => true, }; }; }, addCommands() { return { insertFileAttachment: (options: { src: string, filename: string, filesize?: string }) => ({ commands }) => { return commands.insertContent({ type: this.name, attrs: options, }); }, }; }, }); declare module '@tiptap/core' { interface Commands { fileAttachment: { insertFileAttachment: (options: { src: string, filename: string, filesize?: string }) => ReturnType; }; } }