import { useState, useEffect, useCallback, useRef } from 'react' import { useEditor, EditorContent } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Underline from '@tiptap/extension-underline' import TextStyle from '@tiptap/extension-text-style' import FontFamily from '@tiptap/extension-font-family' import { Color } from '@tiptap/extension-color' import Highlight from '@tiptap/extension-highlight' import TextAlign from '@tiptap/extension-text-align' import Image from '@tiptap/extension-image' import Link from '@tiptap/extension-link' import Table from '@tiptap/extension-table' import TableRow from '@tiptap/extension-table-row' import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' import CharacterCount from '@tiptap/extension-character-count' import Placeholder from '@tiptap/extension-placeholder' import { marked } from 'marked' import { FontSize } from './extensions/FontSize' import { Video } from './extensions/Video' import { Header } from './components/Header' import { Toolbar } from './components/Toolbar' import { Sidebar } from './components/Sidebar' import { MediaModal } from './components/MediaModal' import { api } from './api' /* ── Route params ── */ const _parts = window.location.pathname.split('/') const ARTICLE_ID = _parts[_parts.length - 1] const TOKEN = new URLSearchParams(window.location.search).get('t') || '' const IS_NEW = ARTICLE_ID === 'new' const FONTS = [ 'Arimo', 'Caladea', 'Cormorant Garamond', 'Cousine', 'Crimson Text', 'EB Garamond', 'Fira Code', 'Inter', 'JetBrains Mono', 'Josefin Sans', 'Lato', 'Libre Baskerville', 'Libre Franklin', 'Lora', 'Merriweather', 'Nunito', 'Open Sans', 'Oswald', 'Outfit', 'Patrick Hand', 'Playfair Display', 'Poppins', 'Roboto Mono', 'Roboto', 'Source Code Pro', 'Tinos', ] const DEFAULT_META = { id: null, title: '', slug: '', excerpt: '', cover_url: null, source_url: null, font_family: 'Merriweather', category_id: null, tag_names: [], status: 'draft', } export default function App() { const [meta, setMeta] = useState(DEFAULT_META) const [categories, setCategories] = useState([]) const [saveStatus, setSaveStatus] = useState('saved') 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 /* Refs so callbacks always see latest values without re-creating them */ const metaRef = useRef(meta) metaRef.current = meta const currentIdRef = useRef(IS_NEW ? null : ARTICLE_ID) const saveTimerRef = 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 ── */ const editor = useEditor({ extensions: [ StarterKit, Underline, TextStyle, FontFamily.configure({ types: ['textStyle'] }), FontSize, Color, Highlight.configure({ multicolor: true }), TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }), Image.configure({ allowBase64: false }), Video, Link.configure({ openOnClick: false, HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' } }), Table.configure({ resizable: true }), TableRow, TableCell, TableHeader, Placeholder.configure({ placeholder: 'Начните писать статью…' }), CharacterCount, ], onUpdate: () => { setSaveStatus('unsaved') clearTimeout(saveTimerRef.current) // Always call the latest doSave via ref — avoids stale closure saveTimerRef.current = setTimeout(() => doSaveRef.current?.(), 3000) }, }) // Keep editorRef current every render so doSave never uses a stale instance editorRef.current = editor /* ── Load categories once on mount ── */ useEffect(() => { 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* {}) }, [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 editor = editorRef.current if (!editor) return const m = metaRef.current setSaveStatus('saving') const payload = { title: m.title || 'Без названия', slug: m.slug || undefined, content: rawHtmlRef.current ?? editor.getHTML(), excerpt: m.excerpt, cover_url: m.cover_url || null, source_url: m.source_url || null, font_family: m.font_family, category_id: m.category_id || null, tag_names: m.tag_names, status: overrideStatus ?? m.status, } try { let saved if (!currentIdRef.current) { saved = await api.createArticle(payload, TOKEN) currentIdRef.current = saved.id setMeta(m => ({ ...m, id: saved.id, slug: saved.slug })) window.history.replaceState(null, '', `/editor/${saved.id}?t=${TOKEN}`) } else { saved = await api.updateArticle(currentIdRef.current, payload, TOKEN) } setSaveStatus('saved') if (overrideStatus) { setMeta(m => ({ ...m, status: overrideStatus })) } } catch { setSaveStatus('error') } }, []) // stable: reads from editorRef + metaRef // Keep ref current so the auto-save timer always calls the latest version doSaveRef.current = doSave const handleUploadCover = async file => { try { const res = await api.uploadMedia(file, TOKEN) setMeta(m => ({ ...m, cover_url: res.url })) } catch {} } /* ── Render ── */ return (