import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useEditor, EditorContent } from '@tiptap/react' import { Node, mergeAttributes } from '@tiptap/core' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import { Table } from '@tiptap/extension-table' import { TableRow } from '@tiptap/extension-table-row' import { TableHeader } from '@tiptap/extension-table-header' import { TableCell } from '@tiptap/extension-table-cell' import Placeholder from '@tiptap/extension-placeholder' import Underline from '@tiptap/extension-underline' import Mention from '@tiptap/extension-mention' import mentionSuggestion from './mentionSuggestion' import EmojiPicker from './EmojiPicker' import RichImage from './RichImageNode' import RichCompare from './RichCompareNode' import RichTableControls, { TableInsertDialog } from './RichTableControls' function ToolbarBtn({ onClick, active, disabled, title, children, className = '' }) { return ( ) } function Divider() { return
} function normalizeHttpUrl(rawValue) { const trimmed = String(rawValue || '').trim() if (trimmed === '') { return null } const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed.replace(/^\/+/, '')}` try { const parsed = new URL(withProtocol) if (!['http:', 'https:'].includes(parsed.protocol)) { return null } return parsed.toString() } catch { return null } } function normalizeVideoEmbedUrl(rawValue) { const normalized = normalizeHttpUrl(rawValue) if (!normalized) { return null } const parsed = new URL(normalized) const host = parsed.hostname.replace(/^www\./i, '').toLowerCase() const path = parsed.pathname if (host === 'youtu.be') { const videoId = path.replace(/^\//, '').split('/')[0] return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized } if (host === 'youtube.com' || host === 'm.youtube.com') { if (path === '/watch') { const videoId = parsed.searchParams.get('v') return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized } const pathMatch = path.match(/^\/(embed|shorts|live)\/([^/?#]+)/i) if (pathMatch?.[2]) { return `https://www.youtube.com/embed/${pathMatch[2]}` } } return normalized } function detectSocialPlatform(rawUrl) { const normalized = normalizeHttpUrl(rawUrl) if (!normalized) { return { platform: 'social', label: 'Social post', url: null } } const host = new URL(normalized).hostname.replace(/^www\./i, '').toLowerCase() if (host.includes('instagram.')) return { platform: 'instagram', label: 'Instagram post', url: normalized } if (host.includes('facebook.')) return { platform: 'facebook', label: 'Facebook post', url: normalized } if (host.includes('tiktok.')) return { platform: 'tiktok', label: 'TikTok post', url: normalized } if (host.includes('twitter.') || host.includes('x.com')) return { platform: 'x', label: 'X post', url: normalized } if (host.includes('linkedin.')) return { platform: 'linkedin', label: 'LinkedIn post', url: normalized } if (host.includes('threads.')) return { platform: 'threads', label: 'Threads post', url: normalized } if (host.includes('pinterest.')) return { platform: 'pinterest', label: 'Pinterest pin', url: normalized } return { platform: 'social', label: 'Social post', url: normalized } } const ArtworkEmbed = Node.create({ name: 'artworkEmbed', group: 'block', atom: true, addAttributes() { return { title: { default: 'Artwork' }, url: { default: '' }, thumb: { default: '' }, } }, parseHTML() { return [{ tag: 'figure[data-artwork-embed]' }] }, renderHTML({ HTMLAttributes }) { const preview = [] if (HTMLAttributes.thumb) { preview.push([ 'img', { src: HTMLAttributes.thumb, alt: HTMLAttributes.title || 'Artwork', class: 'block h-auto w-full object-cover', loading: 'lazy', }, ]) } preview.push([ 'figcaption', { class: 'news-embed-caption' }, HTMLAttributes.title || 'Artwork', ]) return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-artwork-embed': 'true', class: 'news-embed news-embed-artwork', }), [ 'a', { href: HTMLAttributes.url || '#', class: 'news-embed-link', rel: 'noopener noreferrer nofollow', target: '_blank', }, ...preview, ], ] }, }) const VideoEmbed = Node.create({ name: 'videoEmbed', group: 'block', atom: true, addAttributes() { return { src: { default: '' }, url: { default: '' }, title: { default: 'Embedded video' }, } }, parseHTML() { return [{ tag: 'figure[data-video-embed]' }] }, renderHTML({ HTMLAttributes }) { return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-video-embed': 'true', class: 'news-embed news-embed-video w-full', }), [ 'iframe', { src: HTMLAttributes.src || '', title: HTMLAttributes.title || 'Embedded video', loading: 'lazy', frameborder: '0', allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share', allowfullscreen: 'true', referrerpolicy: 'strict-origin-when-cross-origin', }, ], ] }, }) const SocialEmbed = Node.create({ name: 'socialEmbed', group: 'block', atom: true, addAttributes() { return { url: { default: '' }, platform: { default: 'social' }, label: { default: 'Social post' }, } }, parseHTML() { return [{ tag: 'figure[data-social-embed]' }] }, renderHTML({ HTMLAttributes }) { const platform = String(HTMLAttributes.platform || 'social') const url = HTMLAttributes.url || '#' const label = HTMLAttributes.label || 'Social post' if (platform === 'instagram') { return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-social-embed': 'true', 'data-platform': platform, class: 'news-embed news-embed-social', }), [ 'blockquote', { class: 'instagram-media', 'data-instgrm-captioned': 'true', 'data-instgrm-permalink': url, 'data-instgrm-version': '14', }, [ 'a', { href: url, rel: 'noopener noreferrer nofollow', target: '_blank' }, label, ], ], ] } if (platform === 'facebook') { return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-social-embed': 'true', 'data-platform': platform, class: 'news-embed news-embed-social', }), [ 'div', { class: 'fb-post', 'data-href': url, 'data-show-text': 'true' }, [ 'blockquote', { cite: url, class: 'fb-xfbml-parse-ignore' }, [ 'a', { href: url, rel: 'noopener noreferrer nofollow', target: '_blank' }, label, ], ], ], ] } if (platform === 'tiktok') { return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-social-embed': 'true', 'data-platform': platform, class: 'news-embed news-embed-social', }), [ 'blockquote', { class: 'tiktok-embed', cite: url }, [ 'section', null, [ 'a', { href: url, rel: 'noopener noreferrer nofollow', target: '_blank' }, label, ], ], ], ] } if (platform === 'x') { return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-social-embed': 'true', 'data-platform': platform, class: 'news-embed news-embed-social', }), [ 'blockquote', { class: 'twitter-tweet' }, [ 'a', { href: url, rel: 'noopener noreferrer nofollow', target: '_blank' }, label, ], ], ] } return [ 'figure', mergeAttributes(HTMLAttributes, { 'data-social-embed': 'true', 'data-platform': platform, class: 'news-embed news-embed-social', }), [ 'a', { href: url, class: 'news-embed-link', rel: 'noopener noreferrer nofollow', target: '_blank', }, ['span', { class: 'news-embed-badge' }, label], ['span', { class: 'news-embed-url' }, url], ], ] }, }) function ArtworkPickerDialog({ open, query, items, loading, onQueryChange, onClose, onSearch, onSelect, }) { if (!open) return null return createPortal(
{ if (event.target === event.currentTarget) { onClose?.() } }} role="presentation" >
Artwork embed

Choose artwork

Search existing artworks and insert a linked artwork card into the News article body.

onQueryChange?.(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault() onSearch?.() } }} placeholder="Search by title, slug, or creator" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
{loading ?
Searching artworks…
: null} {!loading && (!Array.isArray(items) || items.length === 0) ?
No artworks found yet. Try a broader title or creator search.
: null} {!loading && Array.isArray(items) && items.length > 0 ? (
{items.map((item) => { const previewImage = item.image || item.avatar || '' return ( ) })}
) : null}
, document.body, ) } function MediaImageDialog({ open, uploading, error, previewUrl, imageUrl, altText, uploadLabel, helperText, allowUpload, onClose, onImageUrlChange, onAltTextChange, onPickFile, onBrowseAssets, onInsert, onClearUploaded, }) { const inputRef = useRef(null) if (!open) return null return createPortal(
{ if (event.target === event.currentTarget) { onClose?.() } }} role="presentation" >
Media

Add image

Upload, drag, paste, or link an image without leaving the editor.

{allowUpload ? (
!uploading && inputRef.current?.click()} onKeyDown={(event) => { if (uploading) return if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() inputRef.current?.click() } }} onDragOver={(event) => { event.preventDefault() }} onDrop={(event) => { event.preventDefault() void onPickFile?.(event.dataTransfer?.files?.[0] || null) }} className={[ 'rounded-[24px] border border-dashed px-5 py-5 transition outline-none', uploading ? 'cursor-progress border-sky-300/35 bg-sky-400/10' : 'cursor-pointer border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]', ].join(' ')} >
{uploading ? 'Uploading image…' : uploadLabel || 'Drop image here or browse'}
{helperText}
You can also paste an image directly into the editor or drag one into the writing area.
{ void onPickFile?.(event.target.files?.[0] || null) event.target.value = '' }} />
) : null} {error ?
{error}
: null}
{previewUrl ? ( Media preview ) : (
No media selected yet.
)}
{allowUpload ? ( ) : null} {onBrowseAssets ? ( ) : null} {previewUrl ? ( ) : null}
, document.body, ) } function CompareImageDialog({ open, uploading, error, subtitle, leftImage, rightImage, allowUpload, onClose, onSubtitleChange, onLeftAltTextChange, onRightAltTextChange, onLeftPickFile, onRightPickFile, onLeftBrowseAssets, onRightBrowseAssets, onLeftClear, onRightClear, onInsert, }) { const leftInputRef = useRef(null) const rightInputRef = useRef(null) if (!open) return null const renderSide = (sideLabel, image, inputRef, onPickFile, onClear, onAltTextChange, onBrowseAssets) => (
{ if (event.target !== event.currentTarget) { return } if (!uploading && allowUpload) { inputRef.current?.click() } }} onKeyDown={(event) => { if (event.target !== event.currentTarget) { return } if (uploading || !allowUpload) return if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() inputRef.current?.click() } }} onDragOver={(event) => { event.preventDefault() }} onDrop={(event) => { event.preventDefault() void onPickFile?.(event.dataTransfer?.files?.[0] || null) }} className={[ 'grid gap-4 rounded-[24px] border border-dashed px-5 py-5 transition outline-none', uploading ? 'cursor-progress border-sky-300/35 bg-sky-400/10' : 'cursor-pointer border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]', ].join(' ')} >
{sideLabel}
Upload the image for this side of the comparison.
{image.previewUrl ? ( {`${sideLabel} ) : (
No image selected yet.
)}
event.stopPropagation()}> {allowUpload ? ( ) : null} {onBrowseAssets ? ( ) : null} {image.previewUrl ? ( ) : null}
{ void onPickFile?.(event.target.files?.[0] || null) event.target.value = '' }} />
) return createPortal(
{ if (event.target === event.currentTarget) { onClose?.() } }} role="presentation" >
Image comparison

Add side-by-side images

Upload two images, add alt text for each, and include a subtitle under the comparison.

{renderSide('Left image', leftImage, leftInputRef, onLeftPickFile, onLeftClear, onLeftAltTextChange, onLeftBrowseAssets)} {renderSide('Right image', rightImage, rightInputRef, onRightPickFile, onRightClear, onRightAltTextChange, onRightBrowseAssets)}
{error ?
{error}
: null}
, document.body, ) } function AssetPickerDialog({ open, searchQuery, assets, loading, error, pagination, onClose, onRefresh, onSearchQueryChange, onSearch, onPreviousPage, onNextPage, onSelect, }) { if (!open) return null return createPortal(
{ if (event.target === event.currentTarget) { onClose?.() } }} role="presentation" >
Academy assets

Choose an uploaded image

Pick from previously uploaded Academy lesson images instead of uploading a new file.

onSearchQueryChange?.(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault() onSearch?.() } }} placeholder="Search academy assets" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
{loading ?
Loading academy assets…
: null} {error ?
{error}
: null} {!loading && (!Array.isArray(assets) || assets.length === 0) ?
No academy images found yet. Upload one first.
: null} {!loading && Array.isArray(assets) && assets.length > 0 ? (
{assets.map((asset) => ( ))}
) : null}
Page {pagination?.page || 1} of {pagination?.last_page || 1} {typeof pagination?.total === 'number' ? ` · ${pagination.total} assets` : ''}
, document.body, ) } function Toolbar({ editor, advancedNews = false, sourceMode = false, showStructureOutlines = false, showComparisonTool = false, onToggleSourceMode, onToggleStructureOutlines, onInsertArtwork, onInsertImage, onInsertComparison, onInsertTable, onInsertSocialEmbed, onInsertVideoEmbed, onInsertHashtag, editorViewportHeight, onIncreaseEditorViewportHeight, onDecreaseEditorViewportHeight, onResetEditorViewportHeight, }) { if (!editor) return null const addLink = useCallback(() => { const prev = editor.getAttributes('link').href const url = window.prompt('URL', prev ?? 'https://') if (url === null) return if (url === '') { editor.chain().focus().extendMarkRange('link').unsetLink().run() } else { editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run() } }, [editor]) return (
editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold (Ctrl+B)"> editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic (Ctrl+I)"> editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Underline (Ctrl+U)"> editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Strikethrough"> editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2"> H2 editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3"> H3 editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list"> editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list"> 123 editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Quote"> editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Code block"> editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} title="Inline code"> {'{}'} {showComparisonTool ? ( ) : null} editor.chain().focus().setHorizontalRule().run()} title="Horizontal rule"> editor.chain().focus().insertContent('@').run()} title="Mention a user (type @username)"> @ {advancedNews ? ( <> HTML DOM Art Social YT # ) : null}
A-
{editorViewportHeight}rem
A+ Fit editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo (Ctrl+Z)"> editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo (Ctrl+Shift+Z)">
) } export default function RichTextEditor({ content = '', onChange, placeholder = 'Write something…', error, minHeight = 12, autofocus = false, advancedNews = false, searchEntities = null, mediaSupport = null, }) { const viewportStorageKey = 'rich-text-editor.viewport-height' const viewportMinHeight = Math.max(minHeight + 6, 18) const viewportMaxHeight = 42 const viewportStep = 4 const [sourceMode, setSourceMode] = useState(false) const [sourceValue, setSourceValue] = useState(String(content || '')) const [showStructureOutlines, setShowStructureOutlines] = useState(false) const [helperMessage, setHelperMessage] = useState('') const [artworkPickerOpen, setArtworkPickerOpen] = useState(false) const [artworkQuery, setArtworkQuery] = useState('') const [artworkResults, setArtworkResults] = useState([]) const [artworkLoading, setArtworkLoading] = useState(false) const [mediaDialogOpen, setMediaDialogOpen] = useState(false) const [mediaUploading, setMediaUploading] = useState(false) const [mediaError, setMediaError] = useState('') const [mediaUrlValue, setMediaUrlValue] = useState('') const [mediaPreviewUrl, setMediaPreviewUrl] = useState('') const [mediaAltText, setMediaAltText] = useState('') const [mediaUploadedPath, setMediaUploadedPath] = useState('') const [academyAssetsOpen, setAcademyAssetsOpen] = useState(false) const [academyAssetsQuery, setAcademyAssetsQuery] = useState('') const [academyAssetsSearch, setAcademyAssetsSearch] = useState('') const [academyAssetsPage, setAcademyAssetsPage] = useState(1) const [academyAssetsPagination, setAcademyAssetsPagination] = useState({ page: 1, per_page: 24, total: 0, last_page: 1, has_more: false, }) const [academyAssetsLoading, setAcademyAssetsLoading] = useState(false) const [academyAssetsError, setAcademyAssetsError] = useState('') const [academyAssets, setAcademyAssets] = useState([]) const [academyAssetsTarget, setAcademyAssetsTarget] = useState(null) const [compareDialogOpen, setCompareDialogOpen] = useState(false) const [compareUploading, setCompareUploading] = useState(false) const [compareError, setCompareError] = useState('') const [compareSubtitle, setCompareSubtitle] = useState('') const [compareLeftImage, setCompareLeftImage] = useState({ previewUrl: '', altText: '', uploadedPath: '' }) const [compareRightImage, setCompareRightImage] = useState({ previewUrl: '', altText: '', uploadedPath: '' }) const [tableInsertOpen, setTableInsertOpen] = useState(false) const [tableRows, setTableRows] = useState(3) const [tableCols, setTableCols] = useState(3) const [tableHeaderRow, setTableHeaderRow] = useState(true) const [tableHeaderColumn, setTableHeaderColumn] = useState(false) const [editorViewportHeight, setEditorViewportHeight] = useState(() => { if (typeof window === 'undefined') { return Math.min(viewportMaxHeight, viewportMinHeight) } const storedValue = Number(window.localStorage.getItem(viewportStorageKey)) if (Number.isFinite(storedValue)) { return Math.min(viewportMaxHeight, Math.max(viewportMinHeight, storedValue)) } return Math.min(viewportMaxHeight, viewportMinHeight) }) const editorRef = useRef(null) const csrfToken = useMemo(() => { if (typeof document === 'undefined') { return '' } return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' }, []) const deleteTemporaryMedia = useCallback(async (path) => { if (!mediaSupport?.deleteUrl || !path) return const response = await fetch(mediaSupport.deleteUrl, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, Accept: 'application/json', }, credentials: 'same-origin', body: JSON.stringify({ path }), }) const payload = await response.json().catch(() => ({})) if (!response.ok) { throw new Error(payload?.message || payload?.error || 'Could not remove uploaded media.') } }, [csrfToken, mediaSupport]) const resetMediaState = useCallback(() => { setMediaUploading(false) setMediaError('') setMediaUrlValue('') setMediaPreviewUrl('') setMediaAltText('') setMediaUploadedPath('') }, []) const insertImageIntoEditor = useCallback((src, alt = '') => { if (!src || !editorRef.current) return false editorRef.current.chain().focus().insertContent({ type: 'image', attrs: { src, alt: alt || '', title: alt || '', caption: '', width: null, }, }).run() return true }, []) const uploadMediaFile = useCallback(async (file, previousPath = '') => { if (!file || !mediaSupport?.uploadUrl) { return null } if (!String(file.type || '').startsWith('image/')) { throw new Error('Use an image file to insert media.') } setMediaUploading(true) setMediaError('') try { if (previousPath) { await deleteTemporaryMedia(previousPath) } const body = new FormData() body.append('image', file) body.append('slot', mediaSupport.slot || 'body') const response = await fetch(mediaSupport.uploadUrl, { method: 'POST', headers: { 'X-CSRF-TOKEN': csrfToken, Accept: 'application/json', }, credentials: 'same-origin', body, }) const payload = await response.json().catch(() => ({})) if (!response.ok) { throw new Error(payload?.message || payload?.error || 'Image upload failed.') } return payload } finally { setMediaUploading(false) } }, [csrfToken, deleteTemporaryMedia, mediaSupport]) const loadAcademyAssets = useCallback(async ({ page = academyAssetsPage, query = academyAssetsSearch } = {}) => { if (!mediaSupport?.assetsUrl) { setAcademyAssets([]) setAcademyAssetsPagination({ page: 1, per_page: 24, total: 0, last_page: 1, has_more: false }) return } setAcademyAssetsLoading(true) setAcademyAssetsError('') try { const params = new URLSearchParams() params.set('limit', '24') params.set('page', String(Math.max(1, page))) if (String(query || '').trim() !== '') { params.set('q', String(query).trim()) } const requestUrl = new URL(mediaSupport.assetsUrl, window.location.origin) requestUrl.search = params.toString() const response = await fetch(requestUrl.toString(), { headers: { 'X-CSRF-TOKEN': csrfToken, Accept: 'application/json', }, credentials: 'same-origin', cache: 'no-store', }) const payload = await response.json().catch(() => ({})) if (!response.ok) { throw new Error(payload?.message || 'Could not load academy assets.') } setAcademyAssets(Array.isArray(payload?.items) ? payload.items : []) setAcademyAssetsPagination({ page: Number(payload?.pagination?.page) || 1, per_page: Number(payload?.pagination?.per_page) || 24, total: Number(payload?.pagination?.total) || 0, last_page: Number(payload?.pagination?.last_page) || 1, has_more: Boolean(payload?.pagination?.has_more), }) } catch (loadError) { setAcademyAssets([]) setAcademyAssetsPagination({ page: 1, per_page: 24, total: 0, last_page: 1, has_more: false }) setAcademyAssetsError(loadError?.message || 'Could not load academy assets.') } finally { setAcademyAssetsLoading(false) } }, [academyAssetsPage, academyAssetsSearch, csrfToken, mediaSupport?.assetsUrl]) const openAcademyAssets = useCallback((target) => { setAcademyAssetsTarget(target) setAcademyAssetsQuery('') setAcademyAssetsSearch('') setAcademyAssetsPage(1) setAcademyAssetsOpen(true) }, []) const submitAcademyAssetSearch = useCallback(() => { setAcademyAssetsPage(1) setAcademyAssetsSearch(academyAssetsQuery.trim()) }, [academyAssetsQuery]) const goToNextAcademyAssetsPage = useCallback(() => { setAcademyAssetsPage((current) => Math.min(current + 1, academyAssetsPagination.last_page || current + 1)) }, [academyAssetsPagination.last_page]) const goToPreviousAcademyAssetsPage = useCallback(() => { setAcademyAssetsPage((current) => Math.max(1, current - 1)) }, []) const closeAcademyAssets = useCallback(() => { setAcademyAssetsOpen(false) setAcademyAssetsTarget(null) setAcademyAssetsError('') setAcademyAssetsQuery('') setAcademyAssetsSearch('') setAcademyAssetsPage(1) }, []) const chooseAcademyAsset = useCallback((asset) => { if (!asset?.url) { return } const target = academyAssetsTarget || { type: 'media' } if (target.type === 'compare-left') { if (compareLeftImage.uploadedPath) { void deleteTemporaryMedia(compareLeftImage.uploadedPath).catch(() => {}) } setCompareLeftImage({ previewUrl: asset.url, altText: compareLeftImage.altText || asset.name || '', uploadedPath: '', }) } else if (target.type === 'compare-right') { if (compareRightImage.uploadedPath) { void deleteTemporaryMedia(compareRightImage.uploadedPath).catch(() => {}) } setCompareRightImage({ previewUrl: asset.url, altText: compareRightImage.altText || asset.name || '', uploadedPath: '', }) } else { if (mediaUploadedPath) { void deleteTemporaryMedia(mediaUploadedPath).catch(() => {}) } setMediaUploadedPath('') setMediaPreviewUrl(asset.url) setMediaUrlValue(asset.url) setMediaAltText((current) => current || asset.name || '') setMediaError('') } closeAcademyAssets() }, [academyAssetsTarget, closeAcademyAssets, compareLeftImage, compareRightImage, deleteTemporaryMedia, mediaUploadedPath]) const handleEditorImageFile = useCallback(async (file) => { if (!file) return false if (!mediaSupport?.uploadUrl) { setHelperMessage('Image uploads are not configured for this editor.') return false } try { setHelperMessage('Uploading image...') const payload = await uploadMediaFile(file, mediaUploadedPath) if (!payload?.url) { setHelperMessage('Image upload failed.') return false } setMediaUploadedPath(payload?.path || '') setMediaPreviewUrl(payload?.url || '') setMediaUrlValue(payload?.url || '') insertImageIntoEditor(payload.url, file.name?.replace(/\.[^.]+$/, '') || '') setHelperMessage('Image uploaded.') resetMediaState() return true } catch (uploadError) { setMediaError(uploadError?.message || 'Image upload failed.') setHelperMessage('Image upload failed.') return false } }, [insertImageIntoEditor, mediaSupport, mediaUploadedPath, resetMediaState, uploadMediaFile]) const extensions = useMemo(() => { const base = [ StarterKit.configure({ link: false, underline: false, heading: { levels: [2, 3] }, codeBlock: { HTMLAttributes: { class: 'forum-code-block' }, }, }), Underline, Link.configure({ openOnClick: false, HTMLAttributes: { class: 'text-sky-300 underline hover:text-sky-200', rel: 'noopener noreferrer nofollow', }, }), RichImage.configure({ HTMLAttributes: { class: 'rich-image-node' }, }), RichCompare.configure({ HTMLAttributes: { class: 'rich-compare-node' }, }), Table.configure({ resizable: true, allowTableNodeSelection: true, HTMLAttributes: { class: 'rich-table', }, }), TableRow, TableHeader.configure({ HTMLAttributes: { class: 'rich-table__header', }, }), TableCell.configure({ HTMLAttributes: { class: 'rich-table__cell', }, }), Placeholder.configure({ placeholder }), Mention.configure({ HTMLAttributes: { class: 'mention', }, suggestion: mentionSuggestion, }), ] if (advancedNews) { base.push(ArtworkEmbed, VideoEmbed, SocialEmbed) } return base }, [advancedNews, placeholder]) const editor = useEditor({ extensions, immediatelyRender: false, content, autofocus, editorProps: { attributes: { class: [ 'prose prose-invert prose-base max-w-none', 'focus:outline-none', 'px-4 py-3', 'prose-headings:text-white prose-headings:font-bold', 'prose-p:text-zinc-200 prose-p:leading-relaxed prose-p:mb-5', 'prose-h2:mb-5 prose-h3:mb-4 prose-hr:my-8', 'prose-a:text-sky-300 prose-a:no-underline hover:prose-a:text-sky-200', 'prose-blockquote:border-l-sky-500/50 prose-blockquote:text-zinc-400', 'prose-code:text-amber-300 prose-code:bg-white/[0.06] prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:text-xs', 'prose-pre:bg-white/[0.04] prose-pre:border prose-pre:border-white/[0.06] prose-pre:rounded-xl', 'prose-img:rounded-xl', 'prose-hr:border-white/10', ].join(' '), style: `min-height: ${minHeight}rem`, }, handleDrop: (_view, event) => { const file = event.dataTransfer?.files?.[0] if (!file || !String(file.type || '').startsWith('image/')) return false event.preventDefault() void handleEditorImageFile(file) return true }, handlePaste: (_view, event) => { const file = event.clipboardData?.files?.[0] if (!file || !String(file.type || '').startsWith('image/')) return false event.preventDefault() void handleEditorImageFile(file) return true }, }, onUpdate: ({ editor: currentEditor }) => { if (!sourceMode) { onChange?.(currentEditor.getHTML()) } }, }) useEffect(() => { editorRef.current = editor }, [editor]) useEffect(() => { if (!helperMessage) { return undefined } const timeout = window.setTimeout(() => setHelperMessage(''), 2200) return () => window.clearTimeout(timeout) }, [helperMessage]) useEffect(() => { if (!academyAssetsOpen) { return } void loadAcademyAssets({ page: academyAssetsPage, query: academyAssetsSearch }) }, [academyAssetsOpen, academyAssetsPage, academyAssetsSearch, loadAcademyAssets]) useEffect(() => { if (!editor) return if (sourceMode) return if ((content || '') === editor.getHTML()) return editor.commands.setContent(content || '', false) const normalizedHtml = editor.getHTML() if (normalizedHtml !== (content || '')) { onChange?.(normalizedHtml) } }, [content, editor, onChange, sourceMode]) useEffect(() => { if (sourceMode) { setSourceValue(String(content || editor?.getHTML() || '')) } }, [content, editor, sourceMode]) useEffect(() => { if (typeof window === 'undefined') { return } window.localStorage.setItem(viewportStorageKey, String(editorViewportHeight)) }, [editorViewportHeight]) const decreaseEditorViewportHeight = useCallback(() => { setEditorViewportHeight((current) => Math.max(viewportMinHeight, Number((current - viewportStep).toFixed(1)))) }, [viewportMinHeight, viewportStep]) const increaseEditorViewportHeight = useCallback(() => { setEditorViewportHeight((current) => Math.min(viewportMaxHeight, Number((current + viewportStep).toFixed(1)))) }, [viewportMaxHeight, viewportStep]) const resetEditorViewportHeight = useCallback(() => { setEditorViewportHeight(Math.min(viewportMaxHeight, viewportMinHeight)) }, [viewportMaxHeight, viewportMinHeight]) const pushHelperMessage = useCallback((message) => { setHelperMessage(message) }, []) const handleToggleSourceMode = useCallback(() => { if (sourceMode) { setSourceMode(false) if (editor) { editor.commands.setContent(sourceValue || '', false) } pushHelperMessage('Returned to visual editor.') return } setSourceValue(editor?.getHTML() || String(content || '')) setSourceMode(true) }, [content, editor, pushHelperMessage, sourceMode, sourceValue]) const insertArtworkEmbed = useCallback((item) => { if (!editor || !item) return editor.chain().focus().insertContent({ type: 'artworkEmbed', attrs: { title: item.title || 'Embedded artwork', url: item.url || '#', thumb: item.image || item.avatar || '', }, }).run() }, [editor]) const runArtworkSearch = useCallback(async () => { if (typeof searchEntities !== 'function') { return } setArtworkLoading(true) try { const items = await searchEntities('artwork', artworkQuery) setArtworkResults(Array.isArray(items) ? items : []) } finally { setArtworkLoading(false) } }, [artworkQuery, searchEntities]) useEffect(() => { if (!artworkPickerOpen || typeof searchEntities !== 'function') { return } runArtworkSearch() }, [artworkPickerOpen, runArtworkSearch, searchEntities]) const handleInsertArtwork = useCallback(() => { if (!editor) return if (typeof searchEntities === 'function') { setArtworkPickerOpen(true) return } const url = normalizeHttpUrl(window.prompt('Artwork URL', 'https://skinbase.org/art/') || '') if (!url) { pushHelperMessage('Artwork URL is required.') return } const title = window.prompt('Artwork title', 'Embedded artwork') if (title === null) return const thumb = normalizeHttpUrl(window.prompt('Artwork thumbnail URL (optional)', '') || '') || '' insertArtworkEmbed({ title: title || 'Embedded artwork', url, image: thumb, }) }, [editor, insertArtworkEmbed, pushHelperMessage, searchEntities]) const handleInsertImage = useCallback(() => { setMediaDialogOpen(true) setMediaError('') }, []) const handleInsertTable = useCallback(() => { setTableInsertOpen(true) }, []) const handleTableInsert = useCallback(() => { if (!editor) return editor.chain().focus().insertTable({ rows: Math.max(1, tableRows), cols: Math.max(1, tableCols), withHeaderRow: tableHeaderRow, withHeaderColumn: tableHeaderColumn, }).run() setTableInsertOpen(false) }, [editor, tableCols, tableHeaderColumn, tableHeaderRow, tableRows]) const handleMediaDialogClose = useCallback(() => { const uploadedPath = mediaUploadedPath setMediaDialogOpen(false) resetMediaState() if (uploadedPath) { void deleteTemporaryMedia(uploadedPath).catch(() => {}) } }, [deleteTemporaryMedia, mediaUploadedPath, resetMediaState]) const handleMediaInsert = useCallback(() => { const normalizedUrl = normalizeHttpUrl(mediaUrlValue) || mediaUrlValue.trim() if (!normalizedUrl) { setMediaError('Choose or upload an image first.') return } if (!insertImageIntoEditor(normalizedUrl, mediaAltText.trim())) { setMediaError('Editor is not ready yet.') return } setMediaDialogOpen(false) resetMediaState() pushHelperMessage('Image inserted.') }, [insertImageIntoEditor, mediaAltText, mediaUrlValue, pushHelperMessage, resetMediaState]) const handleMediaPickFile = useCallback(async (file) => { if (!file) return try { const payload = await uploadMediaFile(file, mediaUploadedPath) if (!payload?.url) { throw new Error('Image upload failed.') } setMediaUploadedPath(payload?.path || '') setMediaPreviewUrl(payload?.url || '') setMediaUrlValue(payload?.url || '') setMediaError('') } catch (uploadError) { setMediaError(uploadError?.message || 'Image upload failed.') } }, [mediaUploadedPath, uploadMediaFile]) const handleMediaClear = useCallback(() => { const uploadedPath = mediaUploadedPath resetMediaState() if (uploadedPath) { void deleteTemporaryMedia(uploadedPath).catch((deleteError) => { setMediaError(deleteError?.message || 'Could not remove uploaded media.') }) } }, [deleteTemporaryMedia, mediaUploadedPath, resetMediaState]) const handleInsertComparison = useCallback(() => { setCompareDialogOpen(true) setCompareError('') }, []) const resetCompareState = useCallback(() => { setCompareUploading(false) setCompareError('') setCompareSubtitle('') setCompareLeftImage({ previewUrl: '', altText: '', uploadedPath: '' }) setCompareRightImage({ previewUrl: '', altText: '', uploadedPath: '' }) }, []) const handleComparisonDialogClose = useCallback(() => { const uploadedPaths = [compareLeftImage.uploadedPath, compareRightImage.uploadedPath].filter(Boolean) setCompareDialogOpen(false) resetCompareState() uploadedPaths.forEach((path) => { void deleteTemporaryMedia(path).catch(() => {}) }) }, [compareLeftImage.uploadedPath, compareRightImage.uploadedPath, deleteTemporaryMedia, resetCompareState]) const handleComparisonSidePick = useCallback(async (side, file) => { if (!file) return if (!mediaSupport?.uploadUrl) { setCompareError('Image uploads are not configured for this editor.') return } const currentImage = side === 'left' ? compareLeftImage : compareRightImage try { setCompareUploading(true) setCompareError('') const payload = await uploadMediaFile(file, currentImage.uploadedPath) if (!payload?.url) { throw new Error('Image upload failed.') } const nextImage = { previewUrl: payload.url || '', altText: currentImage.altText || file.name?.replace(/\.[^.]+$/, '') || '', uploadedPath: payload.path || '', } if (side === 'left') { setCompareLeftImage(nextImage) } else { setCompareRightImage(nextImage) } } catch (uploadError) { setCompareError(uploadError?.message || 'Image upload failed.') } finally { setCompareUploading(false) } }, [compareLeftImage, compareRightImage, mediaSupport, uploadMediaFile]) const handleComparisonSideClear = useCallback((side) => { const currentImage = side === 'left' ? compareLeftImage : compareRightImage if (currentImage.uploadedPath) { void deleteTemporaryMedia(currentImage.uploadedPath).catch(() => {}) } const nextImage = { previewUrl: '', altText: '', uploadedPath: '' } if (side === 'left') { setCompareLeftImage(nextImage) } else { setCompareRightImage(nextImage) } }, [compareLeftImage, compareRightImage, deleteTemporaryMedia]) const handleComparisonInsert = useCallback(() => { if (!editor) return if (!compareLeftImage.previewUrl || !compareRightImage.previewUrl) { setCompareError('Upload both images before inserting the comparison.') return } editor.chain().focus().insertContent({ type: 'imageCompare', attrs: { leftSrc: compareLeftImage.previewUrl, leftAlt: compareLeftImage.altText.trim(), rightSrc: compareRightImage.previewUrl, rightAlt: compareRightImage.altText.trim(), subtitle: compareSubtitle.trim(), }, }).run() setCompareDialogOpen(false) resetCompareState() pushHelperMessage('Comparison inserted.') }, [compareLeftImage, compareRightImage, compareSubtitle, editor, pushHelperMessage, resetCompareState]) const handleInsertSocialEmbed = useCallback(() => { if (!editor) return const detected = detectSocialPlatform(window.prompt('Social post URL', 'https://') || '') if (!detected.url) { pushHelperMessage('Social post URL is required.') return } const label = window.prompt('Label (optional)', detected.label) if (label === null) return editor.chain().focus().insertContent({ type: 'socialEmbed', attrs: { url: detected.url, platform: detected.platform, label: label || detected.label, }, }).run() }, [editor, pushHelperMessage]) const handleInsertVideoEmbed = useCallback(() => { if (!editor) return const rawUrl = window.prompt('YouTube URL', 'https://www.youtube.com/watch?v=') || '' const embedUrl = normalizeVideoEmbedUrl(rawUrl) const sourceUrl = normalizeHttpUrl(rawUrl) if (!embedUrl || !sourceUrl) { pushHelperMessage('A valid YouTube URL is required.') return } const title = window.prompt('Video title (optional)', 'Embedded YouTube video') if (title === null) return editor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src: embedUrl, url: sourceUrl, title: title.trim() || 'Embedded video', }, }).run() }, [editor, pushHelperMessage]) const handleInsertHashtag = useCallback(() => { if (!editor) return const value = String(window.prompt('Hashtag', 'release') || '').trim().replace(/^#+/, '').replace(/\s+/g, '-') if (!value) return editor.chain().focus().insertContent(`#${value}`).run() }, [editor]) return (
setShowStructureOutlines((current) => !current)} onInsertArtwork={handleInsertArtwork} onInsertImage={handleInsertImage} onInsertComparison={handleInsertComparison} onInsertTable={handleInsertTable} onInsertSocialEmbed={handleInsertSocialEmbed} onInsertVideoEmbed={handleInsertVideoEmbed} onInsertHashtag={handleInsertHashtag} editorViewportHeight={editorViewportHeight} onIncreaseEditorViewportHeight={increaseEditorViewportHeight} onDecreaseEditorViewportHeight={decreaseEditorViewportHeight} onResetEditorViewportHeight={resetEditorViewportHeight} /> {advancedNews && sourceMode ? (
Edit the stored article HTML directly. Saving while in this mode keeps the HTML exactly as written here.