test push
This commit is contained in:
@@ -6,11 +6,9 @@ function fmt(iso) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ArticleModal({ article, onClose }) {
|
export default function ArticleModal({ article, onClose }) {
|
||||||
// Закрытие по Escape
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = e => { if (e.key === 'Escape') onClose() }
|
const handler = e => { if (e.key === 'Escape') onClose() }
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
// Блокируем скролл страницы под модалкой
|
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handler)
|
window.removeEventListener('keydown', handler)
|
||||||
@@ -18,6 +16,8 @@ export default function ArticleModal({ article, onClose }) {
|
|||||||
}
|
}
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
|
const isFullHtml = /^\s*<!DOCTYPE|^\s*<html/i.test(article.content ?? '')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="article-modal-overlay" onClick={onClose}>
|
<div className="article-modal-overlay" onClick={onClose}>
|
||||||
<div className="article-modal" onClick={e => e.stopPropagation()}>
|
<div className="article-modal" onClick={e => e.stopPropagation()}>
|
||||||
@@ -27,42 +27,67 @@ export default function ArticleModal({ article, onClose }) {
|
|||||||
<img className="article-modal__cover" src={article.cover_url} alt={article.title} />
|
<img className="article-modal__cover" src={article.cover_url} alt={article.title} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="article-modal__body">
|
{isFullHtml ? (
|
||||||
<div className="article-meta">
|
<>
|
||||||
{article.category && (
|
<div className="article-modal__body" style={{ paddingBottom: 0 }}>
|
||||||
<span className="chip chip--primary">{article.category.name}</span>
|
<div className="article-meta">
|
||||||
|
{article.category && (
|
||||||
|
<span className="chip chip--primary">{article.category.name}</span>
|
||||||
|
)}
|
||||||
|
{article.published_at && <span>{fmt(article.published_at)}</span>}
|
||||||
|
</div>
|
||||||
|
<h1 className="article-title">{article.title}</h1>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
srcDoc={article.content}
|
||||||
|
style={{ width: '100%', border: 'none', display: 'block', minHeight: '70vh' }}
|
||||||
|
title={article.title}
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
onLoad={e => {
|
||||||
|
const doc = e.target.contentDocument
|
||||||
|
if (doc) e.target.style.height = doc.documentElement.scrollHeight + 'px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="article-modal__body">
|
||||||
|
<div className="article-meta">
|
||||||
|
{article.category && (
|
||||||
|
<span className="chip chip--primary">{article.category.name}</span>
|
||||||
|
)}
|
||||||
|
{article.published_at && <span>{fmt(article.published_at)}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="article-title">{article.title}</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="article-body"
|
||||||
|
style={article.font_family ? { fontFamily: article.font_family } : undefined}
|
||||||
|
dangerouslySetInnerHTML={{ __html: article.content ?? '' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{article.tags?.length > 0 && (
|
||||||
|
<div className="article-tags">
|
||||||
|
{article.tags.map(t => (
|
||||||
|
<span key={t.id} className="chip chip--ghost">#{t.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{article.source_url && (
|
||||||
|
<div className="article-source">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||||
|
</svg>
|
||||||
|
<span>Источник:</span>
|
||||||
|
<a href={article.source_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{article.source_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<span>{fmt(article.published_at)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<h1 className="article-title">{article.title}</h1>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="article-body"
|
|
||||||
dangerouslySetInnerHTML={{ __html: article.content ?? '' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{article.tags?.length > 0 && (
|
|
||||||
<div className="article-tags">
|
|
||||||
{article.tags.map(t => (
|
|
||||||
<span key={t.id} className="chip chip--ghost">#{t.name}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{article.source_url && (
|
|
||||||
<div className="article-source">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
|
||||||
</svg>
|
|
||||||
<span>Источник:</span>
|
|
||||||
<a href={article.source_url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{article.source_url}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,6 +32,28 @@ export default function NewsDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isFullHtml = /^\s*<!DOCTYPE|^\s*<html/i.test(article.content ?? '')
|
||||||
|
|
||||||
|
if (isFullHtml) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ padding: '0.5rem 1rem', background: 'var(--c-surface)', borderBottom: '1px solid var(--c-border)' }}>
|
||||||
|
<button className="back-btn" onClick={() => navigate(-1)}>← Назад</button>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
srcDoc={article.content}
|
||||||
|
style={{ width: '100%', border: 'none', display: 'block' }}
|
||||||
|
title={article.title}
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
onLoad={e => {
|
||||||
|
const doc = e.target.contentDocument
|
||||||
|
if (doc) e.target.style.height = doc.documentElement.scrollHeight + 'px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="article-page">
|
<div className="article-page">
|
||||||
<button className="back-btn" onClick={() => navigate(-1)}>← Назад</button>
|
<button className="back-btn" onClick={() => navigate(-1)}>← Назад</button>
|
||||||
@@ -49,7 +71,8 @@ export default function NewsDetail() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="article-body"
|
className="article-body"
|
||||||
dangerouslySetInnerHTML={{ __html: article.body ?? '' }}
|
style={article.font_family ? { fontFamily: article.font_family } : undefined}
|
||||||
|
dangerouslySetInnerHTML={{ __html: article.content ?? '' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{article.tags?.length > 0 && (
|
{article.tags?.length > 0 && (
|
||||||
|
|||||||
@@ -53,63 +53,27 @@ const DEFAULT_META = {
|
|||||||
status: 'draft',
|
status: 'draft',
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Загрузчик: сначала данные, потом редактор ── */
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [initContent, setInitContent] = useState(IS_NEW ? '' : null) // null = ещё не загружено
|
const [meta, setMeta] = useState(DEFAULT_META)
|
||||||
const [initMeta, setInitMeta] = useState(DEFAULT_META)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (IS_NEW) return
|
|
||||||
api.getArticle(ARTICLE_ID, TOKEN)
|
|
||||||
.then(data => {
|
|
||||||
setInitMeta({
|
|
||||||
id: data.id,
|
|
||||||
title: data.title,
|
|
||||||
slug: data.slug,
|
|
||||||
excerpt: data.excerpt || '',
|
|
||||||
cover_url: data.cover_url || null,
|
|
||||||
source_url: data.source_url || null,
|
|
||||||
font_family: data.font_family || 'Merriweather',
|
|
||||||
category_id: data.category?.id || null,
|
|
||||||
tag_names: (data.tags || []).map(t => t.name),
|
|
||||||
status: data.status,
|
|
||||||
})
|
|
||||||
const html = data.content?.startsWith('<')
|
|
||||||
? data.content
|
|
||||||
: marked.parse(data.content || '')
|
|
||||||
setInitContent(html || '')
|
|
||||||
})
|
|
||||||
.catch(() => setInitContent(''))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Пока данные не загружены — показываем лоадер
|
|
||||||
if (initContent === null) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', height: '100vh', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: '#f8fafc', color: '#94a3b8', fontSize: 14 }}>
|
|
||||||
Загрузка статьи…
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <EditorApp initContent={initContent} initMeta={initMeta} />
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Сам редактор — рендерится только когда данные готовы ── */
|
|
||||||
function EditorApp({ initContent, initMeta }) {
|
|
||||||
const [meta, setMeta] = useState(initMeta)
|
|
||||||
const [categories, setCategories] = useState([])
|
const [categories, setCategories] = useState([])
|
||||||
const [saveStatus, setSaveStatus] = useState('saved')
|
const [saveStatus, setSaveStatus] = useState('saved')
|
||||||
const [mediaState, setMediaState] = useState(null)
|
const [mediaState, setMediaState] = useState(null)
|
||||||
|
const [htmlMode, setHtmlMode] = useState(false)
|
||||||
|
const [htmlDraft, setHtmlDraft] = useState('')
|
||||||
|
const [rawHtml, setRawHtml] = useState(null) // полный HTML-документ, обходит TipTap
|
||||||
|
const rawHtmlRef = useRef(null)
|
||||||
|
rawHtmlRef.current = rawHtml
|
||||||
|
|
||||||
const metaRef = useRef(meta)
|
/* Refs so callbacks always see latest values without re-creating them */
|
||||||
metaRef.current = meta
|
const metaRef = useRef(meta)
|
||||||
const currentIdRef = useRef(initMeta.id ?? (IS_NEW ? null : ARTICLE_ID))
|
metaRef.current = meta
|
||||||
const saveTimerRef = useRef(null)
|
const currentIdRef = useRef(IS_NEW ? null : ARTICLE_ID)
|
||||||
const editorRef = useRef(null)
|
const saveTimerRef = useRef(null)
|
||||||
const doSaveRef = useRef(null)
|
const editorRef = useRef(null) // always points to live editor instance
|
||||||
|
const articleLoadedRef = useRef(false)
|
||||||
|
const doSaveRef = useRef(null) // always points to latest doSave
|
||||||
|
|
||||||
/* ── Editor создаётся сразу с готовым контентом ── */
|
/* ── Editor ── */
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit,
|
||||||
@@ -130,22 +94,62 @@ function EditorApp({ initContent, initMeta }) {
|
|||||||
Placeholder.configure({ placeholder: 'Начните писать статью…' }),
|
Placeholder.configure({ placeholder: 'Начните писать статью…' }),
|
||||||
CharacterCount,
|
CharacterCount,
|
||||||
],
|
],
|
||||||
content: initContent,
|
|
||||||
onUpdate: () => {
|
onUpdate: () => {
|
||||||
setSaveStatus('unsaved')
|
setSaveStatus('unsaved')
|
||||||
clearTimeout(saveTimerRef.current)
|
clearTimeout(saveTimerRef.current)
|
||||||
|
// Always call the latest doSave via ref — avoids stale closure
|
||||||
saveTimerRef.current = setTimeout(() => doSaveRef.current?.(), 3000)
|
saveTimerRef.current = setTimeout(() => doSaveRef.current?.(), 3000)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Keep editorRef current every render so doSave never uses a stale instance
|
||||||
editorRef.current = editor
|
editorRef.current = editor
|
||||||
|
|
||||||
/* ── Категории ── */
|
/* ── Load categories once on mount ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getCategories(TOKEN).then(setCategories).catch(() => {})
|
api.getCategories(TOKEN).then(setCategories).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/* ── Сохранение ── */
|
/* ── Load article once the editor is ready ── */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || IS_NEW || articleLoadedRef.current) return
|
||||||
|
articleLoadedRef.current = true
|
||||||
|
|
||||||
|
api.getArticle(ARTICLE_ID, TOKEN)
|
||||||
|
.then(data => {
|
||||||
|
setMeta({
|
||||||
|
id: data.id,
|
||||||
|
title: data.title,
|
||||||
|
slug: data.slug,
|
||||||
|
excerpt: data.excerpt || '',
|
||||||
|
cover_url: data.cover_url || null,
|
||||||
|
source_url: data.source_url || null,
|
||||||
|
font_family: data.font_family || 'Merriweather',
|
||||||
|
category_id: data.category?.id || null,
|
||||||
|
tag_names: (data.tags || []).map(t => t.name),
|
||||||
|
status: data.status,
|
||||||
|
})
|
||||||
|
const html = data.content?.startsWith('<')
|
||||||
|
? data.content
|
||||||
|
: marked.parse(data.content || '')
|
||||||
|
const isFullDoc = /^\s*<!DOCTYPE|^\s*<html/i.test(html)
|
||||||
|
if (isFullDoc) {
|
||||||
|
setRawHtml(html)
|
||||||
|
editor.commands.setContent('', false)
|
||||||
|
} else {
|
||||||
|
setRawHtml(null)
|
||||||
|
editor.commands.setContent(html, false)
|
||||||
|
}
|
||||||
|
setSaveStatus('saved')
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
|
/* ── Save ─────────────────────────────────────────────────────────────────
|
||||||
|
Uses editorRef + metaRef so this callback is stable (no deps that change).
|
||||||
|
doSaveRef is updated every render so the auto-save timer always calls the
|
||||||
|
freshest version.
|
||||||
|
── */
|
||||||
const doSave = useCallback(async overrideStatus => {
|
const doSave = useCallback(async overrideStatus => {
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
@@ -153,15 +157,16 @@ function EditorApp({ initContent, initMeta }) {
|
|||||||
setSaveStatus('saving')
|
setSaveStatus('saving')
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: m.title || 'Без названия',
|
title: m.title || 'Без названия',
|
||||||
slug: m.slug || undefined,
|
slug: m.slug || undefined,
|
||||||
content: editor.getHTML(),
|
content: rawHtmlRef.current ?? editor.getHTML(),
|
||||||
excerpt: m.excerpt,
|
excerpt: m.excerpt,
|
||||||
cover_url: m.cover_url || null,
|
cover_url: m.cover_url || null,
|
||||||
|
source_url: m.source_url || null,
|
||||||
font_family: m.font_family,
|
font_family: m.font_family,
|
||||||
category_id: m.category_id || null,
|
category_id: m.category_id || null,
|
||||||
tag_names: m.tag_names,
|
tag_names: m.tag_names,
|
||||||
status: overrideStatus ?? m.status,
|
status: overrideStatus ?? m.status,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -175,12 +180,15 @@ function EditorApp({ initContent, initMeta }) {
|
|||||||
saved = await api.updateArticle(currentIdRef.current, payload, TOKEN)
|
saved = await api.updateArticle(currentIdRef.current, payload, TOKEN)
|
||||||
}
|
}
|
||||||
setSaveStatus('saved')
|
setSaveStatus('saved')
|
||||||
if (overrideStatus) setMeta(m => ({ ...m, status: overrideStatus }))
|
if (overrideStatus) {
|
||||||
|
setMeta(m => ({ ...m, status: overrideStatus }))
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setSaveStatus('error')
|
setSaveStatus('error')
|
||||||
}
|
}
|
||||||
}, [])
|
}, []) // stable: reads from editorRef + metaRef
|
||||||
|
|
||||||
|
// Keep ref current so the auto-save timer always calls the latest version
|
||||||
doSaveRef.current = doSave
|
doSaveRef.current = doSave
|
||||||
|
|
||||||
const handleUploadCover = async file => {
|
const handleUploadCover = async file => {
|
||||||
@@ -190,6 +198,7 @@ function EditorApp({ initContent, initMeta }) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Render ── */
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen overflow-hidden bg-slate-50" style={{ fontFamily: 'Inter, sans-serif' }}>
|
<div className="flex flex-col h-screen overflow-hidden bg-slate-50" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
|
|
||||||
@@ -214,6 +223,26 @@ function EditorApp({ initContent, initMeta }) {
|
|||||||
type: 'video',
|
type: 'video',
|
||||||
cb: url => editor.chain().focus().insertVideo({ src: url }).run(),
|
cb: url => editor.chain().focus().insertVideo({ src: url }).run(),
|
||||||
})}
|
})}
|
||||||
|
onEditHtml={() => {
|
||||||
|
if (htmlMode) {
|
||||||
|
const isFullDoc = /^\s*<!DOCTYPE|^\s*<html/i.test(htmlDraft)
|
||||||
|
if (isFullDoc) {
|
||||||
|
// Полный HTML-документ — обходим TipTap, сохраняем как есть
|
||||||
|
setRawHtml(htmlDraft)
|
||||||
|
editor.commands.setContent('', false)
|
||||||
|
} else {
|
||||||
|
// Фрагмент — отдаём в TipTap
|
||||||
|
setRawHtml(null)
|
||||||
|
editor.commands.setContent(htmlDraft, true)
|
||||||
|
}
|
||||||
|
setHtmlMode(false)
|
||||||
|
} else {
|
||||||
|
// Открываем редактор: показываем rawHtml или HTML из TipTap
|
||||||
|
setHtmlDraft(rawHtml ?? editor.getHTML())
|
||||||
|
setHtmlMode(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
htmlMode={htmlMode}
|
||||||
fonts={FONTS}
|
fonts={FONTS}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -229,12 +258,45 @@ function EditorApp({ initContent, initMeta }) {
|
|||||||
onChange={e => setMeta(m => ({ ...m, title: e.target.value }))}
|
onChange={e => setMeta(m => ({ ...m, title: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* TipTap content */}
|
{/* HTML source textarea */}
|
||||||
<div style={{ fontFamily: meta.font_family }}>
|
{htmlMode && (
|
||||||
<EditorContent editor={editor} />
|
<textarea
|
||||||
</div>
|
spellCheck={false}
|
||||||
|
value={htmlDraft}
|
||||||
|
onChange={e => setHtmlDraft(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%', minHeight: '60vh',
|
||||||
|
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||||
|
fontSize: '0.8rem', lineHeight: 1.7,
|
||||||
|
color: '#1e293b', background: '#f8fafc',
|
||||||
|
border: '1px solid #e2e8f0', borderRadius: 8,
|
||||||
|
padding: '1rem', resize: 'vertical', outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TipTap visual editor */}
|
||||||
|
{!htmlMode && !rawHtml && (
|
||||||
|
<div style={{ fontFamily: meta.font_family }}>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Full HTML iframe — вне узкого контейнера, на всю ширину колонки */}
|
||||||
|
{!htmlMode && rawHtml && (
|
||||||
|
<iframe
|
||||||
|
srcDoc={rawHtml}
|
||||||
|
style={{ width: '100%', border: 'none', display: 'block' }}
|
||||||
|
title="HTML preview"
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
onLoad={e => {
|
||||||
|
const doc = e.target.contentDocument
|
||||||
|
if (doc) e.target.style.height = doc.documentElement.scrollHeight + 'px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export function MediaModal({ type, token, onSelect, onClose }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listMedia(token, type)
|
api.listMedia(token, type)
|
||||||
.then(data => { setAllItems(data); setItems(data); setLoading(false) })
|
.then(data => {
|
||||||
|
const list = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
|
setAllItems(list); setItems(list); setLoading(false)
|
||||||
|
})
|
||||||
.catch(() => setLoading(false))
|
.catch(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -35,60 +35,38 @@ function Sep() {
|
|||||||
return <div className="w-px h-5 bg-slate-200 mx-0.5 flex-shrink-0" />
|
return <div className="w-px h-5 bg-slate-200 mx-0.5 flex-shrink-0" />
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Hook: позиция дропдауна через fixed (вырывается из overflow-контейнеров) ── */
|
|
||||||
|
|
||||||
function useFixedDropdown() {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
|
||||||
const btnRef = useRef(null)
|
|
||||||
|
|
||||||
const toggle = e => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!open && btnRef.current) {
|
|
||||||
const r = btnRef.current.getBoundingClientRect()
|
|
||||||
setPos({ top: r.bottom + 4, left: r.left })
|
|
||||||
}
|
|
||||||
setOpen(o => !o)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
const close = e => {
|
|
||||||
if (!btnRef.current?.contains(e.target)) setOpen(false)
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', close)
|
|
||||||
return () => document.removeEventListener('mousedown', close)
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
return { open, setOpen, pos, btnRef, toggle }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Font family dropdown ── */
|
/* ── Font family dropdown ── */
|
||||||
|
|
||||||
function FontDropdown({ editor, fonts }) {
|
function FontDropdown({ editor, fonts }) {
|
||||||
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef(null)
|
||||||
const current = editor.getAttributes('textStyle').fontFamily || 'Шрифт'
|
const current = editor.getAttributes('textStyle').fontFamily || 'Шрифт'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
|
||||||
|
document.addEventListener('mousedown', h)
|
||||||
|
return () => document.removeEventListener('mousedown', h)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0">
|
<div className="relative flex-shrink-0" ref={ref}>
|
||||||
<button
|
<button
|
||||||
ref={btnRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={toggle}
|
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
|
||||||
className="flex items-center gap-1 px-2 h-[30px] rounded-md text-xs text-slate-600 hover:bg-slate-100 border border-transparent hover:border-slate-200 transition-colors max-w-[130px] cursor-pointer"
|
className="flex items-center gap-1 px-2 h-[30px] rounded-md text-xs text-slate-600 hover:bg-slate-100 border border-transparent hover:border-slate-200 transition-colors max-w-[130px] cursor-pointer"
|
||||||
title="Шрифт"
|
title="Шрифт"
|
||||||
>
|
>
|
||||||
<span className="truncate leading-none" style={{ fontFamily: current !== 'Шрифт' ? current : undefined }}>
|
<span
|
||||||
|
className="truncate leading-none"
|
||||||
|
style={{ fontFamily: current !== 'Шрифт' ? current : undefined }}
|
||||||
|
>
|
||||||
{current}
|
{current}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown size={11} className="flex-shrink-0 opacity-60" />
|
<ChevronDown size={11} className="flex-shrink-0 opacity-60" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-52 max-h-72 overflow-y-auto py-1.5">
|
||||||
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 208 }}
|
|
||||||
className="bg-white border border-slate-200 rounded-xl shadow-2xl max-h-72 overflow-y-auto py-1.5"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full px-3 py-1.5 text-left text-xs text-slate-400 hover:bg-slate-50 transition-colors"
|
className="w-full px-3 py-1.5 text-left text-xs text-slate-400 hover:bg-slate-50 transition-colors"
|
||||||
@@ -117,15 +95,21 @@ function FontDropdown({ editor, fonts }) {
|
|||||||
/* ── Font size dropdown ── */
|
/* ── Font size dropdown ── */
|
||||||
|
|
||||||
function SizeDropdown({ editor }) {
|
function SizeDropdown({ editor }) {
|
||||||
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef(null)
|
||||||
const current = editor.getAttributes('textStyle').fontSize?.replace('px', '') || '—'
|
const current = editor.getAttributes('textStyle').fontSize?.replace('px', '') || '—'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
|
||||||
|
document.addEventListener('mousedown', h)
|
||||||
|
return () => document.removeEventListener('mousedown', h)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0">
|
<div className="relative flex-shrink-0" ref={ref}>
|
||||||
<button
|
<button
|
||||||
ref={btnRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={toggle}
|
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
|
||||||
className="flex items-center gap-1 px-1.5 h-[30px] w-14 rounded-md text-xs text-slate-600 hover:bg-slate-100 border border-transparent hover:border-slate-200 transition-colors cursor-pointer"
|
className="flex items-center gap-1 px-1.5 h-[30px] w-14 rounded-md text-xs text-slate-600 hover:bg-slate-100 border border-transparent hover:border-slate-200 transition-colors cursor-pointer"
|
||||||
title="Размер шрифта"
|
title="Размер шрифта"
|
||||||
>
|
>
|
||||||
@@ -134,10 +118,7 @@ function SizeDropdown({ editor }) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-20 max-h-56 overflow-y-auto py-1.5">
|
||||||
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 80 }}
|
|
||||||
className="bg-white border border-slate-200 rounded-xl shadow-2xl max-h-56 overflow-y-auto py-1.5"
|
|
||||||
>
|
|
||||||
{FONT_SIZES.map(s => (
|
{FONT_SIZES.map(s => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
@@ -158,23 +139,13 @@ function SizeDropdown({ editor }) {
|
|||||||
|
|
||||||
function TableMenu({ editor }) {
|
function TableMenu({ editor }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
const ref = useRef(null)
|
||||||
const wrapRef = useRef(null)
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
if (!open && wrapRef.current) {
|
|
||||||
const r = wrapRef.current.getBoundingClientRect()
|
|
||||||
setPos({ top: r.bottom + 4, left: r.left })
|
|
||||||
}
|
|
||||||
setOpen(o => !o)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
|
||||||
const close = e => { if (!wrapRef.current?.contains(e.target)) setOpen(false) }
|
document.addEventListener('mousedown', h)
|
||||||
document.addEventListener('mousedown', close)
|
return () => document.removeEventListener('mousedown', h)
|
||||||
return () => document.removeEventListener('mousedown', close)
|
}, [])
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
{ label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||||
@@ -191,9 +162,9 @@ function TableMenu({ editor }) {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapRef} className="flex-shrink-0">
|
<div className="relative flex-shrink-0" ref={ref}>
|
||||||
<Btn
|
<Btn
|
||||||
onClick={toggle}
|
onClick={() => setOpen(o => !o)}
|
||||||
active={editor.isActive('table')}
|
active={editor.isActive('table')}
|
||||||
title="Таблица"
|
title="Таблица"
|
||||||
>
|
>
|
||||||
@@ -201,10 +172,7 @@ function TableMenu({ editor }) {
|
|||||||
</Btn>
|
</Btn>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-52 py-1.5">
|
||||||
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 208 }}
|
|
||||||
className="bg-white border border-slate-200 rounded-xl shadow-2xl py-1.5"
|
|
||||||
>
|
|
||||||
{items.map((item, i) =>
|
{items.map((item, i) =>
|
||||||
item === null ? (
|
item === null ? (
|
||||||
<div key={i} className="my-1 border-t border-slate-100" />
|
<div key={i} className="my-1 border-t border-slate-100" />
|
||||||
@@ -213,7 +181,9 @@ function TableMenu({ editor }) {
|
|||||||
key={item.label}
|
key={item.label}
|
||||||
type="button"
|
type="button"
|
||||||
className={`w-full px-3 py-1.5 text-left text-xs transition-colors
|
className={`w-full px-3 py-1.5 text-left text-xs transition-colors
|
||||||
${item.danger ? 'text-red-500 hover:bg-red-50' : 'text-slate-700 hover:bg-slate-50'}`}
|
${item.danger
|
||||||
|
? 'text-red-500 hover:bg-red-50'
|
||||||
|
: 'text-slate-700 hover:bg-slate-50'}`}
|
||||||
onMouseDown={e => { e.preventDefault(); item.fn(); setOpen(false) }}
|
onMouseDown={e => { e.preventDefault(); item.fn(); setOpen(false) }}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -228,7 +198,7 @@ function TableMenu({ editor }) {
|
|||||||
|
|
||||||
/* ── Main Toolbar ── */
|
/* ── Main Toolbar ── */
|
||||||
|
|
||||||
export function Toolbar({ editor, onInsertImage, onInsertVideo, fonts }) {
|
export function Toolbar({ editor, onInsertImage, onInsertVideo, onEditHtml, htmlMode, fonts }) {
|
||||||
if (!editor) return null
|
if (!editor) return null
|
||||||
|
|
||||||
const setLink = () => {
|
const setLink = () => {
|
||||||
@@ -240,7 +210,7 @@ export function Toolbar({ editor, onInsertImage, onInsertVideo, fonts }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0.5 px-3 py-1.5 bg-white border-b border-slate-200 shadow-sm overflow-x-auto flex-shrink-0 min-h-[46px]" style={{ scrollbarWidth: 'none' }}>
|
<div className="sticky top-0 z-10 flex items-center gap-0.5 px-3 py-1.5 bg-white border-b border-slate-200 shadow-sm overflow-x-auto flex-wrap min-h-[46px]">
|
||||||
{/* History */}
|
{/* History */}
|
||||||
<Btn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Отменить (Ctrl+Z)">
|
<Btn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Отменить (Ctrl+Z)">
|
||||||
<Undo2 size={14} />
|
<Undo2 size={14} />
|
||||||
@@ -305,9 +275,7 @@ export function Toolbar({ editor, onInsertImage, onInsertVideo, fonts }) {
|
|||||||
<Btn onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Цитата">
|
<Btn onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Цитата">
|
||||||
<Quote size={14} />
|
<Quote size={14} />
|
||||||
</Btn>
|
</Btn>
|
||||||
<Btn onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Блок кода">
|
|
||||||
<Code2 size={14} />
|
|
||||||
</Btn>
|
|
||||||
<Btn onClick={() => editor.chain().focus().setHorizontalRule().run()} title="Разделитель">
|
<Btn onClick={() => editor.chain().focus().setHorizontalRule().run()} title="Разделитель">
|
||||||
<Minus size={14} />
|
<Minus size={14} />
|
||||||
</Btn>
|
</Btn>
|
||||||
@@ -324,6 +292,19 @@ export function Toolbar({ editor, onInsertImage, onInsertVideo, fonts }) {
|
|||||||
<Video size={14} />
|
<Video size={14} />
|
||||||
</Btn>
|
</Btn>
|
||||||
<TableMenu editor={editor} />
|
<TableMenu editor={editor} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => { e.preventDefault(); onEditHtml?.() }}
|
||||||
|
title={htmlMode ? 'Вернуться в редактор (применить HTML)' : 'Редактировать HTML-код'}
|
||||||
|
style={{
|
||||||
|
height: 30, padding: '0 8px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||||
|
fontFamily: 'monospace', fontSize: 12, fontWeight: 700, flexShrink: 0,
|
||||||
|
background: htmlMode ? '#4f46e5' : 'transparent',
|
||||||
|
color: htmlMode ? '#fff' : '#64748b',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!htmlMode) e.target.style.background = '#f1f5f9' }}
|
||||||
|
onMouseLeave={e => { if (!htmlMode) e.target.style.background = 'transparent' }}
|
||||||
|
></></button>
|
||||||
<Sep />
|
<Sep />
|
||||||
|
|
||||||
{/* Typography */}
|
{/* Typography */}
|
||||||
|
|||||||
Reference in New Issue
Block a user