import { Node, mergeAttributes } from '@tiptap/core'; export interface VideoOptions { HTMLAttributes: Record; } export const Video = Node.create({ name: 'video', group: 'block', // Allows it to be selected and manipulated as a single block selectable: true, draggable: true, addOptions() { return { HTMLAttributes: { class: 'w-full rounded-lg border border-border/50 shadow-sm overflow-hidden my-4', controls: true, }, }; }, addAttributes() { return { src: { default: null, }, width: { default: null, parseHTML: element => element.getAttribute('width'), renderHTML: attributes => { if (!attributes.width) return {}; return { width: attributes.width }; }, }, filename: { default: null, parseHTML: element => element.getAttribute('data-filename'), renderHTML: attributes => { if (!attributes.filename) return {}; return { 'data-filename': attributes.filename }; }, }, }; }, addCommands() { return { setVideo: (options: { src: string, filename?: string }) => ({ commands }) => { return commands.insertContent({ type: this.name, attrs: options, }); }, }; }, parseHTML() { return [ { tag: 'video', }, ]; }, renderHTML({ HTMLAttributes }) { return ['video', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; }, 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 wrapper = document.createElement('div'); wrapper.classList.add('video-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'max-w-full'); wrapper.style.display = 'block'; wrapper.style.marginInline = 'auto'; // Center if desired if (node.attrs.width) { const w = typeof node.attrs.width === 'number' ? `${node.attrs.width}px` : node.attrs.width; wrapper.style.width = w; } else { wrapper.style.width = '100%'; } const video = document.createElement('video'); Object.entries(node.attrs).forEach(([key, value]) => { if (value !== null && value !== undefined) { if (key === 'filename') { video.setAttribute('data-filename', value); } else { video.setAttribute(key, value); } } }); // Apply default attributes if not present if (!video.hasAttribute('controls')) { video.setAttribute('controls', 'true'); } video.classList.add('w-full', 'rounded-lg', 'border', 'border-border/50', 'shadow-sm', 'overflow-hidden'); video.style.width = '100%'; // Resize handle const resizer = document.createElement('div'); // Adding style.touchAction = 'none' to prevent mobile browser scrolling when dragging resizer.style.touchAction = 'none'; resizer.innerHTML = ''; resizer.classList.add('absolute', 'bottom-2', 'right-2', 'w-6', 'h-6', 'bg-black/50', 'hover:bg-black/80', 'backdrop-blur-sm', 'cursor-nwse-resize', 'transition-opacity', 'z-20', 'rounded-md', 'flex', 'items-center', 'justify-center', 'shadow-sm'); resizer.contentEditable = 'false'; let isResizing = false; let startX = 0; let startWidth = 0; const initResize = (clientX: number) => { isResizing = true; startX = clientX; startWidth = wrapper.offsetWidth; }; const doResize = (clientX: number, minW: number) => { if (!isResizing) return; const dx = clientX - startX; const newWidth = Math.max(minW, startWidth + dx); wrapper.style.width = `${newWidth}px`; }; const stopResize = () => { if (!isResizing) return; isResizing = false; if (typeof getPos === 'function') { const pos = getPos(); if (typeof pos === 'number') { editor.commands.command(({ tr }) => { tr.setNodeMarkup(pos, undefined, { ...node.attrs, width: wrapper.offsetWidth, }); return true; }); } } }; resizer.addEventListener('mousedown', (e) => { // Prevent event from dragging the whole block e.preventDefault(); e.stopPropagation(); initResize(e.clientX); const onMouseMove = (ev: MouseEvent) => { ev.preventDefault(); doResize(ev.clientX, 200); }; const onMouseUp = (ev: MouseEvent) => { ev.preventDefault(); stopResize(); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); resizer.addEventListener('touchstart', (e) => { e.preventDefault(); e.stopPropagation(); initResize(e.touches[0].clientX); const onTouchMove = (ev: TouchEvent) => { ev.preventDefault(); doResize(ev.touches[0].clientX, 200); }; const onTouchEnd = () => { stopResize(); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); document.removeEventListener('touchcancel', onTouchEnd); }; document.addEventListener('touchmove', onTouchMove, { passive: false }); document.addEventListener('touchend', onTouchEnd); document.addEventListener('touchcancel', onTouchEnd); }, { passive: false }); // Delete button overlay const overlay = document.createElement('div'); overlay.classList.add('absolute', 'top-2', 'left-2', 'flex', '[@media(hover:hover)]:hidden', '[@media(hover:hover)]: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(); 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(video); wrapper.appendChild(resizer); wrapper.appendChild(overlay); return { dom: wrapper, stopEvent: () => true, // Stops events from propagating to editor (makes the input usable) ignoreMutation: () => true, }; }; }, }); declare module '@tiptap/core' { interface Commands { video: { setVideo: (options: { src: string, filename?: string }) => ReturnType; }; } }