import { Node, mergeAttributes } from '@tiptap/core'; export interface FileViewerOptions { HTMLAttributes: Record; } declare module '@tiptap/core' { interface Commands { fileViewer: { insertFileViewer: (options?: { src?: string }) => ReturnType; }; } } export const FileViewer = Node.create({ name: 'fileViewer', group: 'block', selectable: true, draggable: true, addOptions() { return { HTMLAttributes: { class: 'file-viewer-node', }, }; }, addAttributes() { return { src: { default: null, parseHTML: element => { const iframe = element.querySelector('iframe'); if (iframe) { return iframe.getAttribute('src'); } return element.getAttribute('src'); }, renderHTML: attributes => { if (!attributes.src) { return {}; } return { src: attributes.src, }; } }, }; }, parseHTML() { return [ { tag: 'div[data-type="file-viewer-wrapper"]', } ]; }, renderHTML({ HTMLAttributes }) { const { src, ...rest } = HTMLAttributes; return ['div', mergeAttributes(this.options.HTMLAttributes, rest, { 'data-type': 'file-viewer-wrapper', class: 'file-viewer-wrapper relative w-full my-4 rounded-xl overflow-hidden border border-border flex min-h-[400px] bg-muted/20' }), ['iframe', { 'data-type': 'file-viewer', src: src, class: 'w-full h-full border-0 absolute inset-0' }] ]; }, addNodeView() { return ({ node, getPos, editor }) => { const wrapper = document.createElement('div'); wrapper.classList.add('file-viewer-wrapper', 'relative', 'w-full', 'my-4', 'rounded-xl', 'overflow-hidden', 'border', 'border-border', 'flex', 'bg-muted/10', 'group'); if (node.attrs.src) { // Typical file viewer mode wrapper.style.minHeight = '400px'; const iframe = document.createElement('iframe'); iframe.src = node.attrs.src; iframe.classList.add('w-full', 'h-full', 'border-0', 'absolute', 'inset-0'); // Add a small drag handle / delete button overlay that appears on hover const overlay = document.createElement('div'); overlay.classList.add('absolute', 'top-2', 'right-2', 'hidden', 'group-hover:flex', 'bg-background/80', '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(); 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(iframe); wrapper.appendChild(overlay); } else { // Form mode wrapper.classList.add('items-center', 'justify-center', 'flex-col', 'p-8', 'gap-4'); const icon = document.createElement('div'); // AppWindow Lucide Icon icon.innerHTML = ''; const title = document.createElement('div'); title.textContent = 'Embed File or Website'; title.classList.add('font-medium', 'text-sm', 'text-muted-foreground'); const form = document.createElement('form'); form.classList.add('flex', 'w-full', 'max-w-md', 'gap-2', 'relative', 'z-10'); const input = document.createElement('input'); input.type = 'url'; input.placeholder = 'https://...'; input.classList.add('flex-1', 'bg-background', 'border', 'border-border', 'rounded-lg', 'px-3', 'py-1.5', 'text-sm', 'outline-none', 'focus:ring-1', 'focus:ring-primary/50'); const button = document.createElement('button'); button.type = 'submit'; button.textContent = 'Embed'; button.classList.add('bg-primary', 'text-primary-foreground', 'px-3', 'py-1.5', 'rounded-lg', 'text-sm', 'font-medium', 'hover:bg-primary/90', 'cursor-pointer'); form.onsubmit = (e) => { e.preventDefault(); const url = input.value.trim(); if (url && typeof getPos === 'function') { editor.commands.command(({ tr }) => { const pos = getPos(); if (typeof pos === 'number') { tr.setNodeMarkup(pos, undefined, { src: url }); return true; } return false; }); } }; form.appendChild(input); form.appendChild(button); wrapper.appendChild(icon); wrapper.appendChild(title); wrapper.appendChild(form); // Clicking wrapper focuses input wrapper.onclick = () => { input.focus(); }; } return { dom: wrapper, stopEvent: () => true, ignoreMutation: () => true, }; }; }, addCommands() { return { insertFileViewer: (options?: { src?: string }) => ({ commands }) => { return commands.insertContent({ type: this.name, attrs: options || {}, }); }, }; }, });