Files
TasGrid/src/lib/extensions/file-viewer.ts
T
2026-03-06 18:50:21 -06:00

175 lines
7.0 KiB
TypeScript

import { Node, mergeAttributes } from '@tiptap/core';
export interface FileViewerOptions {
HTMLAttributes: Record<string, any>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fileViewer: {
insertFileViewer: (options?: { src?: string }) => ReturnType;
};
}
}
export const FileViewer = Node.create<FileViewerOptions>({
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 = '<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();
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground/50"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="M10 4v4"></path><path d="M2 8h20"></path><path d="M6 4v4"></path></svg>';
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 || {},
});
},
};
},
});