908 lines
33 KiB
JavaScript
908 lines
33 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, 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 Image from '@tiptap/extension-image'
|
|
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'
|
|
|
|
function ToolbarBtn({ onClick, active, disabled, title, children, className = '' }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onMouseDown={(event) => {
|
|
event.preventDefault()
|
|
}}
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
title={title}
|
|
className={[
|
|
'inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-colors',
|
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400',
|
|
active
|
|
? 'bg-sky-600/25 text-sky-300'
|
|
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
|
|
disabled && 'pointer-events-none opacity-30',
|
|
className,
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function Divider() {
|
|
return <div className="mx-1 h-5 w-px bg-white/10" />
|
|
}
|
|
|
|
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',
|
|
}),
|
|
[
|
|
'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',
|
|
},
|
|
],
|
|
[
|
|
'figcaption',
|
|
{ class: 'news-embed-caption' },
|
|
[
|
|
'a',
|
|
{
|
|
href: HTMLAttributes.url || HTMLAttributes.src || '#',
|
|
rel: 'noopener noreferrer nofollow',
|
|
target: '_blank',
|
|
},
|
|
HTMLAttributes.title || 'Watch video',
|
|
],
|
|
],
|
|
]
|
|
},
|
|
})
|
|
|
|
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(
|
|
<div
|
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
|
|
onClick={(event) => {
|
|
if (event.target === event.currentTarget) {
|
|
onClose?.()
|
|
}
|
|
}}
|
|
role="presentation"
|
|
>
|
|
<div className="w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
|
<div className="border-b border-white/[0.06] px-6 py-5">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Artwork embed</div>
|
|
<h3 className="mt-2 text-lg font-semibold text-white">Choose artwork</h3>
|
|
<p className="mt-2 text-sm leading-6 text-white/65">Search existing artworks and insert a linked artwork card into the News article body.</p>
|
|
</div>
|
|
|
|
<div className="border-b border-white/[0.06] px-6 py-4">
|
|
<div className="flex gap-3">
|
|
<input
|
|
value={query}
|
|
onChange={(event) => 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"
|
|
/>
|
|
<button type="button" onClick={onSearch} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
|
|
Search
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="nova-scrollbar max-h-[60vh] overflow-y-auto px-6 py-5">
|
|
{loading ? <div className="rounded-2xl border border-white/10 bg-black/20 p-4 text-sm text-slate-300">Searching artworks…</div> : null}
|
|
{!loading && (!Array.isArray(items) || items.length === 0) ? <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No artworks found yet. Try a broader title or creator search.</div> : null}
|
|
{!loading && Array.isArray(items) && items.length > 0 ? (
|
|
<div className="grid gap-3">
|
|
{items.map((item) => {
|
|
const previewImage = item.image || item.avatar || ''
|
|
|
|
return (
|
|
<button
|
|
key={`${item.entity_type || 'artwork'}-${item.id}`}
|
|
type="button"
|
|
onClick={() => onSelect?.(item)}
|
|
className="flex items-center gap-4 rounded-[24px] border border-white/10 bg-black/20 p-3 text-left transition hover:border-white/20 hover:bg-white/[0.04]"
|
|
>
|
|
<div className="h-20 w-28 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-white/[0.03]">
|
|
{previewImage ? <img src={previewImage} alt={item.title} className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-xs uppercase tracking-[0.18em] text-slate-500">No thumb</div>}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-semibold text-white">{item.title}</div>
|
|
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
|
{item.description ? <div className="mt-2 line-clamp-2 text-xs leading-5 text-slate-400">{item.description}</div> : null}
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
|
<button type="button" onClick={onClose} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|
|
|
|
function Toolbar({
|
|
editor,
|
|
advancedNews = false,
|
|
sourceMode = false,
|
|
showStructureOutlines = false,
|
|
onToggleSourceMode,
|
|
onToggleStructureOutlines,
|
|
onInsertArtwork,
|
|
onInsertSocialEmbed,
|
|
onInsertVideoEmbed,
|
|
onInsertHashtag,
|
|
}) {
|
|
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])
|
|
|
|
const addImage = useCallback(() => {
|
|
const url = window.prompt('Image URL', 'https://')
|
|
if (url) {
|
|
editor.chain().focus().setImage({ src: url }).run()
|
|
}
|
|
}, [editor])
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-0.5 border-b border-white/[0.06] px-2.5 py-2">
|
|
<ToolbarBtn onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold (Ctrl+B)">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6zm0 8h9a4 4 0 014 4 4 4 0 01-4 4H6z"/></svg>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic (Ctrl+I)">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Underline (Ctrl+U)">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 3v7a6 6 0 006 6 6 6 0 006-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Strikethrough">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="4" y1="12" x2="20" y2="12"/><path d="M17.5 7.5c0-2-1.5-3.5-5.5-3.5S6.5 5.5 6.5 7.5c0 4 11 4 11 8 0 2-1.5 3.5-5.5 3.5s-5.5-1.5-5.5-3.5"/></svg>
|
|
</ToolbarBtn>
|
|
|
|
<Divider />
|
|
|
|
<ToolbarBtn onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2">
|
|
<span className="text-xs font-bold">H2</span>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3">
|
|
<span className="text-xs font-bold">H3</span>
|
|
</ToolbarBtn>
|
|
|
|
<Divider />
|
|
|
|
<ToolbarBtn onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1" fill="currentColor"/><circle cx="4.5" cy="12" r="1" fill="currentColor"/><circle cx="4.5" cy="18" r="1" fill="currentColor"/></svg>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="3" y="8" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">1</text><text x="3" y="14" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">2</text><text x="3" y="20" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">3</text></svg>
|
|
</ToolbarBtn>
|
|
|
|
<Divider />
|
|
|
|
<ToolbarBtn onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Quote">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.24 11 15.14c0 .94-.36 1.84-1.001 2.503A3.34 3.34 0 017.559 18.6a3.77 3.77 0 01-2.976-.879zm10.4 0C13.953 16.227 13.4 15 13.4 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.986.169 3.395 1.729 3.395 3.629 0 .94-.36 1.84-1.001 2.503a3.34 3.34 0 01-2.44.957 3.77 3.77 0 01-2.976-.879z"/></svg>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Code block">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} title="Inline code">
|
|
<span className="font-mono text-[11px] font-bold">{'{}'}</span>
|
|
</ToolbarBtn>
|
|
|
|
<Divider />
|
|
|
|
<ToolbarBtn onClick={addLink} active={editor.isActive('link')} title="Link">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={addImage} title="Insert image">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
|
</ToolbarBtn>
|
|
|
|
<Divider />
|
|
|
|
<ToolbarBtn onClick={() => editor.chain().focus().setHorizontalRule().run()} title="Horizontal rule">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="12" x2="21" y2="12"/></svg>
|
|
</ToolbarBtn>
|
|
<EmojiPicker editor={editor} />
|
|
<ToolbarBtn onClick={() => editor.chain().focus().insertContent('@').run()} title="Mention a user (type @username)">
|
|
<span className="text-xs font-bold">@</span>
|
|
</ToolbarBtn>
|
|
|
|
{advancedNews ? (
|
|
<>
|
|
<Divider />
|
|
<ToolbarBtn onClick={onToggleSourceMode} active={sourceMode} title="View or edit source HTML" className="w-auto px-2.5">
|
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">HTML</span>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={onToggleStructureOutlines} active={showStructureOutlines} title="Outline blocks (p, div, figure, list)" className="w-auto px-2.5">
|
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">DOM</span>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={onInsertArtwork} title="Embed artwork" className="w-auto px-2.5">
|
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">Art</span>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={onInsertSocialEmbed} title="Embed social post" className="w-auto px-2.5">
|
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">Social</span>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={onInsertVideoEmbed} title="Embed YouTube" className="w-auto px-2.5">
|
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">YT</span>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={onInsertHashtag} title="Insert hashtag" className="w-auto px-2.5">
|
|
<span className="text-xs font-bold">#</span>
|
|
</ToolbarBtn>
|
|
</>
|
|
) : null}
|
|
|
|
<div className="ml-auto flex items-center gap-0.5">
|
|
<ToolbarBtn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo (Ctrl+Z)">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
|
|
</ToolbarBtn>
|
|
<ToolbarBtn onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo (Ctrl+Shift+Z)">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.13-9.36L23 10"/></svg>
|
|
</ToolbarBtn>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function RichTextEditor({
|
|
content = '',
|
|
onChange,
|
|
placeholder = 'Write something…',
|
|
error,
|
|
minHeight = 12,
|
|
autofocus = false,
|
|
advancedNews = false,
|
|
searchEntities = null,
|
|
}) {
|
|
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 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',
|
|
},
|
|
}),
|
|
Image.configure({
|
|
HTMLAttributes: { class: 'rounded-lg max-w-full' },
|
|
}),
|
|
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-sm 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-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`,
|
|
},
|
|
},
|
|
onUpdate: ({ editor: currentEditor }) => {
|
|
if (!sourceMode) {
|
|
onChange?.(currentEditor.getHTML())
|
|
}
|
|
},
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (!helperMessage) {
|
|
return undefined
|
|
}
|
|
|
|
const timeout = window.setTimeout(() => setHelperMessage(''), 2200)
|
|
return () => window.clearTimeout(timeout)
|
|
}, [helperMessage])
|
|
|
|
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])
|
|
|
|
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 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 || 'Embedded YouTube 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 (
|
|
<div className="flex flex-col gap-1.5">
|
|
<div
|
|
className={[
|
|
'news-rich-text-editor overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
|
|
error
|
|
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
|
|
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
|
|
].join(' ')}
|
|
>
|
|
<Toolbar
|
|
editor={editor}
|
|
advancedNews={advancedNews}
|
|
sourceMode={sourceMode}
|
|
showStructureOutlines={showStructureOutlines}
|
|
onToggleSourceMode={handleToggleSourceMode}
|
|
onToggleStructureOutlines={() => setShowStructureOutlines((current) => !current)}
|
|
onInsertArtwork={handleInsertArtwork}
|
|
onInsertSocialEmbed={handleInsertSocialEmbed}
|
|
onInsertVideoEmbed={handleInsertVideoEmbed}
|
|
onInsertHashtag={handleInsertHashtag}
|
|
/>
|
|
|
|
{advancedNews && sourceMode ? (
|
|
<div className="border-t border-white/[0.04] bg-black/10 px-4 py-3">
|
|
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-slate-400">
|
|
<span>Edit the stored article HTML directly. Saving while in this mode keeps the HTML exactly as written here.</span>
|
|
<button type="button" onClick={handleToggleSourceMode} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 font-semibold text-white transition hover:bg-white/[0.08]">
|
|
Back to visual
|
|
</button>
|
|
</div>
|
|
<textarea
|
|
value={sourceValue}
|
|
onChange={(event) => {
|
|
const nextValue = event.target.value
|
|
setSourceValue(nextValue)
|
|
onChange?.(nextValue)
|
|
}}
|
|
spellCheck={false}
|
|
className="nova-scrollbar min-h-[20rem] w-full rounded-xl border border-white/10 bg-slate-950/85 px-4 py-3 font-mono text-sm leading-6 text-slate-100 outline-none"
|
|
style={{ minHeight: `${Math.max(minHeight, 20)}rem` }}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className={advancedNews && showStructureOutlines ? 'news-editor-outline' : ''}>
|
|
<EditorContent editor={editor} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{advancedNews && helperMessage ? (
|
|
<p className="text-xs text-sky-300">{helperMessage}</p>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<p role="alert" className="text-xs text-red-400">{error}</p>
|
|
) : null}
|
|
|
|
<ArtworkPickerDialog
|
|
open={artworkPickerOpen}
|
|
query={artworkQuery}
|
|
items={artworkResults}
|
|
loading={artworkLoading}
|
|
onQueryChange={setArtworkQuery}
|
|
onClose={() => setArtworkPickerOpen(false)}
|
|
onSearch={runArtworkSearch}
|
|
onSelect={(item) => {
|
|
insertArtworkEmbed(item)
|
|
setArtworkPickerOpen(false)
|
|
pushHelperMessage(`Embedded artwork: ${item.title || 'Artwork'}.`)
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|