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.
{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 ? (

) : (
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 ? (

) : (
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.
{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">
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.
) : (
)}
{advancedNews && helperMessage ? (
{helperMessage}
) : null}
{!sourceMode ? (
) : null}
{error ? (
{error}
) : null}
setArtworkPickerOpen(false)}
onSearch={runArtworkSearch}
onSelect={(item) => {
insertArtworkEmbed(item)
setArtworkPickerOpen(false)
pushHelperMessage(`Embedded artwork: ${item.title || 'Artwork'}.`)
}}
/>
{
setMediaUrlValue(nextValue)
setMediaPreviewUrl(normalizeHttpUrl(nextValue) || nextValue)
setMediaError('')
}}
onAltTextChange={setMediaAltText}
onPickFile={handleMediaPickFile}
onBrowseAssets={mediaSupport?.assetsUrl ? () => openAcademyAssets({ type: 'media' }) : null}
onInsert={handleMediaInsert}
onClearUploaded={handleMediaClear}
/>
setCompareLeftImage((current) => ({ ...current, altText: nextValue }))}
onRightAltTextChange={(nextValue) => setCompareRightImage((current) => ({ ...current, altText: nextValue }))}
onLeftPickFile={(file) => handleComparisonSidePick('left', file)}
onRightPickFile={(file) => handleComparisonSidePick('right', file)}
onLeftBrowseAssets={mediaSupport?.assetsUrl ? () => openAcademyAssets({ type: 'compare-left' }) : null}
onRightBrowseAssets={mediaSupport?.assetsUrl ? () => openAcademyAssets({ type: 'compare-right' }) : null}
onLeftClear={() => handleComparisonSideClear('left')}
onRightClear={() => handleComparisonSideClear('right')}
onInsert={handleComparisonInsert}
/>
loadAcademyAssets({ page: academyAssetsPage, query: academyAssetsSearch })}
onSearchQueryChange={setAcademyAssetsQuery}
onSearch={submitAcademyAssetSearch}
onPreviousPage={goToPreviousAcademyAssetsPage}
onNextPage={goToNextAcademyAssetsPage}
onSelect={chooseAcademyAsset}
/>
setTableInsertOpen(false)}
onInsert={handleTableInsert}
/>
)
}