Files
TasGrid/src/lib/extensions/file-attachment.ts
T

238 lines
11 KiB
TypeScript

import { Node, mergeAttributes } from '@tiptap/core';
export interface FileAttachmentOptions {
HTMLAttributes: Record<string, any>;
}
export const FileAttachment = Node.create<FileAttachmentOptions>({
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 = '<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><path d="M14 2v4a2 2 0 0 0 2 2h4"></path><path d="M10 9H8"></path><path d="M16 13H8"></path><path d="M16 17H8"></path></svg>';
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
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<ReturnType> {
fileAttachment: {
insertFileAttachment: (options: { src: string, filename: string, filesize?: string }) => ReturnType;
};
}
}