import { mergeAttributes, Node } from '@tiptap/core'; import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewProps, } from '@tiptap/react'; import { __ } from '@wordpress/i18n'; import { Expand, Pencil, Trash2, X } from 'lucide-react'; import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; declare const wp: { media: (options: { title?: string; button?: { text: string }; multiple?: boolean; library?: { type?: string }; }) => { on: (event: string, cb: () => void) => void; open: () => void; state: () => { get: (key: string) => { first: () => { toJSON: () => { url: string; filename: string } }; }; }; }; }; function openWpMedia( libraryType: string, title: string, onSelect: (url: string, filename: string) => void, ) { if (typeof wp === 'undefined' || typeof wp.media !== 'function') { console.error( 'wp.media is not available. Ensure wp_enqueue_media() is called.', ); return; } const frame = wp.media({ title, button: { text: __('Use this file', 'allcoach') }, multiple: false, library: { type: libraryType }, }); frame.on('select', () => { const attachment = frame.state().get('selection').first().toJSON(); onSelect(attachment.url, attachment.filename); }); frame.open(); } type Attrs = { url: string; title: string }; type MediaFieldConfig = { /** Node name, e.g. 'imageBlock' */ name: string; /** HTML data-type attribute, e.g. 'image-block' */ dataType: string; /** wp.media library filter, e.g. 'image' */ libraryType: 'image' | 'video' | 'audio'; /** Badge / dialog title label, e.g. 'Add Image' */ addLabel: string; /** Subtitle shown in the empty state */ subLabel: string; /** Lucide icon component for the empty state */ icon: React.ElementType; /** Insert command name, e.g. 'insertImageBlock' */ commandName: string; }; export function createMediaField({ name, dataType, libraryType, addLabel, subLabel, icon, commandName, }: MediaFieldConfig) { return Node.create({ name, group: 'block', atom: true, selectable: true, draggable: true, addAttributes() { return { url: { default: '', renderHTML: (attrs) => ({ 'data-url': attrs.url }), parseHTML: (el) => el.getAttribute('data-url') ?? '', }, title: { default: '', renderHTML: (attrs) => ({ 'data-title': attrs.title }), parseHTML: (el) => el.getAttribute('data-title') ?? '', }, autoFocus: { default: false, renderHTML: () => ({}) }, }; }, parseHTML() { return [{ tag: `div[data-type="${dataType}"]` }]; }, renderHTML({ HTMLAttributes }) { return [ 'div', mergeAttributes(HTMLAttributes, { 'data-type': dataType }), ]; }, addNodeView() { return ReactNodeViewRenderer((props: ReactNodeViewProps) => ( )); }, addCommands() { return { [commandName]: (attrs: Partial = {}) => ({ chain }: any) => chain() .insertContent({ type: name, attrs: { url: attrs.url ?? '', title: attrs.title ?? '', autoFocus: true, }, }) .run(), }; }, }); } type MediaFieldComponentProps = ReactNodeViewProps & { libraryType: 'image' | 'video' | 'audio'; addLabel: string; subLabel: string; icon: React.ElementType; }; function MediaFieldComponent({ node, updateAttributes, libraryType, addLabel, subLabel, icon: Icon, }: MediaFieldComponentProps) { const { url, title, autoFocus } = node.attrs as Attrs & { autoFocus: boolean; }; const hasMedia = !!url; const [lightbox, setLightbox] = useState(false); // Open the picker immediately when freshly inserted useEffect(() => { if (autoFocus) { updateAttributes({ autoFocus: false }); // handleOpen(); } }, []); useEffect(() => { if (!lightbox) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setLightbox(false); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [lightbox]); const handleOpen = () => { openWpMedia(libraryType, addLabel, (newUrl, filename) => { updateAttributes({ url: newUrl, title: title || filename }); }); }; const handleRemove = () => updateAttributes({ url: '', title: '' }); return ( {/* Header */} updateAttributes({ title: e.target.value })} placeholder={ url ? (url.split('/').pop()?.split('?')[0] ?? addLabel) : addLabel } className="min-w-0 flex-1 rounded-md bg-transparent py-0.5 pr-6 pl-2 text-[13px] font-semibold text-gray-800 outline-none placeholder:font-normal placeholder:text-gray-400 hover:bg-gray-100 focus:bg-gray-100" /> {hasMedia && ( {libraryType === 'image' && ( setLightbox(true)} className="flex items-center justify-center rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600" title={__('View full size', 'allcoach')} > )} {__('Replace', 'allcoach')} {__('Remove', 'allcoach')} )} {/* Media area */} {!hasMedia ? ( {addLabel} {subLabel} ) : ( <> {libraryType === 'image' && ( )} {libraryType === 'video' && ( )} {libraryType === 'audio' && ( )} > )} {/* Lightbox (image only) */} {lightbox && libraryType === 'image' && createPortal( setLightbox(false)} > e.stopPropagation()} > setLightbox(false)} className="absolute top-3 right-3 z-10 flex size-7 items-center justify-center rounded-full bg-black/40 text-white hover:bg-black/60" > {title && ( {title} )} , document.body, )} ); }