Вход Регистрация
Файл: public/assets/js/tiptap.js
Строк: 1763
<?php
import 
{ Editor, Extension, Node, mergeAttributes } from '@tiptap/core'
import { StarterKit } from '@tiptap/starter-kit'
import { Link } from '@tiptap/extension-link'
import { TextAlign } from '@tiptap/extension-text-align'
import { TextStyle, FontSize } from '@tiptap/extension-text-style'
import { Color } from '@tiptap/extension-color'
import { Image } from '@tiptap/extension-image'
import { Placeholder } from '@tiptap/extension-placeholder'
import { CharacterCount } from '@tiptap/extension-character-count'
import { Mention } from '@tiptap/extension-mention'
import FileHandler from '@tiptap/extension-file-handler'
import { Table, TableRow, TableHeader, TableCell } from '@tiptap/extension-table'
import { __ } from './translate.js'

// Ссылка: inclusive=false чтобы пробел после ссылки не входил в неё
const CustomLink = Link.extend({
    
inclusive() { return false },
})

// Перенос строки (Shift-Enter) дефолтным hardBreak не прокручивает контейнер,
// из-за чего курсор уходит за нижнюю границу. Переопределяем со scrollIntoView.
const HardBreakScroll = Extension.create({
    
name: 'hardBreakScroll',
    
priority: 1000,
    
addKeyboardShortcuts() {
        const 
setHardBreak = () => this.editor.chain().setHardBreak().scrollIntoView().run()
        return {
            
'Shift-Enter': setHardBreak,
            
'Mod-Enter': setHardBreak,
        }
    },
})

const 
BackgroundColor = Extension.create({
    
name: 'backgroundColor',
    
addOptions() { return { types: ['textStyle'] } },
    
addGlobalAttributes() {
        return [{
            
types: this.options.types,
            
attributes: {
                
backgroundColor: {
                    default: 
null,
                    
parseHTML: el => el.style.backgroundColor || null,
                    
renderHTML: attrs => attrs.backgroundColor
                        
? { style: `background-color: ${attrs.backgroundColor}` }
                        : {},
                },
            },
        }]
    },
    
addCommands() {
        return {
            
setHighlight: ({ color }) => ({ chain }) =>
                
chain().setMark('textStyle', { backgroundColor: color }).run(),
            
unsetHighlight: () => ({ chain }) =>
                
chain().setMark('textStyle', { backgroundColor: null }).removeEmptyTextStyle().run(),
        }
    },
})
// ─── Blockquote ───────────────────────────────────────────────────────────────

const Blockquote = Node.create({
    
name: 'blockquote',
    
group: 'block',
    
content: 'block+',
    
defining: true,
    
addAttributes() {
        return { 
author: { default: null } }
    },
    
parseHTML() {
        return [
            {
                
tag: 'blockquote',
                
getAttrs: el => ({ author: el.querySelector(':scope > footer')?.textContent?.trim() || null }),
                
contentElement: el => el.querySelector(':scope > div') || el,
            },
        ]
    },
    
renderHTML({ node }) {
        if (
node.attrs.author) {
            return [
'blockquote', {},
                [
'div', {}, 0],
                [
'footer', {}, node.attrs.author],
            ]
        }
        return [
'blockquote', {}, 0]
    },
    
addCommands() {
        return {
            
toggleBlockquote: (author = null) => ({ commands }) =>
                
commands.toggleWrap(this.name, { author }),
        }
    },
    
addKeyboardShortcuts() {
        return { 
'Mod-Shift-b': () => this.editor.commands.toggleBlockquote() }
    },
})

// ─── VideoEmbed ───────────────────────────────────────────────────────────────

function getEmbedUrl(url) {
    
let m
    m 
= url.match(/(?:youtube.com/(?:watch?v=|embed/)|youtu.be/)([a-zA-Z0-9_-]{11})/)
    if (
m) return `https://www.youtube.com/embed/${m[1]}`
    
m = url.match(/vimeo.com/(d+)/)
    if (
m) return `https://player.vimeo.com/video/${m[1]}`
    
m = url.match(/rutube.ru/video/([a-f0-9]+)/)
    if (
m) return `https://rutube.ru/play/embed/${m[1]}/`
    
m = url.match(/coub.com/view/([a-zA-Z0-9]+)/)
    if (
m) return `https://coub.com/embed/${m[1]}`
    
m = url.match(/(?:vk.com|vkvideo.ru)/video(-?d+_d+)/)
    if (
m) { const [oid, id] = m[1].split('_'); return `https://vk.com/video_ext.php?oid=${oid}&id=${id}&hd=2` }
    
m = url.match(/ok.ru/video/(d+)/)
    if (
m) return `https://ok.ru/videoembed/${m[1]}`
    return 
null
}

function 
getOriginalUrl(embedUrl) {
    
let m
    m 
= embedUrl.match(/youtube.com/embed/([a-zA-Z0-9_-]{11})/)
    if (
m) return `https://www.youtube.com/watch?v=${m[1]}`
    
m = embedUrl.match(/player.vimeo.com/video/(d+)/)
    if (
m) return `https://vimeo.com/${m[1]}`
    
m = embedUrl.match(/rutube.ru/play/embed/([a-f0-9]+)/)
    if (
m) return `https://rutube.ru/video/${m[1]}/`
    
m = embedUrl.match(/coub.com/embed/([a-zA-Z0-9]+)/)
    if (
m) return `https://coub.com/view/${m[1]}`
    
m = embedUrl.match(/vk.com/video_ext.php?oid=(-?d+)&id=(d+)/)
    if (
m) return `https://vk.com/video${m[1]}_${m[2]}`
    
m = embedUrl.match(/ok.ru/videoembed/(d+)/)
    if (
m) return `https://ok.ru/video/${m[1]}`
    return 
embedUrl
}

const 
VideoFile = Node.create({
    
name: 'video',
    
group: 'block',
    
atom: true,
    
addAttributes() { return { src: { default: null } } },
    
parseHTML() { return [{ tag: 'video.video[src]', getAttrs: el => ({ src: el.getAttribute('src') }) }] },
    
renderHTML({ node }) {
        return [
'video', { class: 'video', src: node.attrs.src, controls: 'true', preload: 'metadata' }]
    },
})

const 
VideoEmbed = Node.create({
    
name: 'videoEmbed',
    
group: 'block',
    
atom: true,
    
addAttributes() { return { src: { default: null } } },
    
parseHTML() { return [{ tag: 'div.video', getAttrs: el => ({ src: getOriginalUrl(el.querySelector('iframe')?.src || '') || el.textContent.trim() || '' }) }] },
    
renderHTML({ node }) {
        const 
embedSrc = getEmbedUrl(node.attrs.src)
        if (!
embedSrc) return ['div', { class: 'video' }, node.attrs.src || '']
        return [
'div', { class: 'video' },
            [
'iframe', { src: embedSrc, allowfullscreen: 'true', frameborder: '0', loading: 'lazy' }]]
    },
    
addNodeView() {
        return ({ 
node }) => {
            const 
dom = document.createElement('div')
            
dom.setAttribute('contenteditable', 'false')

            const 
inner = document.createElement('div')
            
inner.className = 'video'

            
const embedSrc = getEmbedUrl(node.attrs.src)

            
let iframe = null
            
if (embedSrc) {
                
iframe = document.createElement('iframe')
                
iframe.src = embedSrc
                iframe
.setAttribute('allowfullscreen', 'true')
                
iframe.setAttribute('frameborder', '0')
                
iframe.loading = 'lazy'
                
inner.appendChild(iframe)
            } else if (
node.attrs.src) {
                
inner.textContent = node.attrs.src
            
}
            
dom.appendChild(inner)

            return {
                
dom,
                
update(updatedNode) {
                    if (
updatedNode.type.name !== 'videoEmbed') return false
                    
const newSrc = getEmbedUrl(updatedNode.attrs.src)
                    if (
iframe && newSrc) {
                        
iframe.src = newSrc
                    
}
                    return 
true
                
},
            }
        }
    },
    
addCommands() {
        return { 
insertVideo: src => ({ commands }) => commands.insertContent({ type: this.name, attrs: { src } }) }
    },
})

// ─── AudioEmbed ───────────────────────────────────────────────────────────────

const AudioEmbed = Node.create({
    
name: 'audioEmbed',
    
group: 'block',
    
atom: true,
    
addAttributes() { return { src: { default: null } } },
    
parseHTML() { return [{ tag: 'audio[controls]', getAttrs: el => ({ src: el.getAttribute('src') }) }] },
    
renderHTML({ node }) {
        return [
'audio', mergeAttributes({ controls: 'true' }, { src: node.attrs.src })]
    },
    
addCommands() {
        return { 
insertAudio: src => ({ commands }) => commands.insertContent({ type: this.name, attrs: { src } }) }
    },
})

// ─── Sticker ──────────────────────────────────────────────────────────────────

const Sticker = Node.create({
    
name: 'sticker',
    
group: 'inline',
    
inline: true,
    
atom: true,
    
addAttributes() {
        return {
            
src: { default: null },
            
alt: { default: null },
        }
    },
    
parseHTML() {
        return [{ 
tag: 'img.sticker', priority: 100 }]
    },
    
renderHTML({ node }) {
        return [
'img', { class: 'sticker', src: node.attrs.src, alt: node.attrs.alt }]
    },
    
addCommands() {
        return {
            
insertSticker: attrs => ({ commands }) => commands.insertContent({ type: this.name, attrs }),
        }
    },
})

// ─── Spoiler ──────────────────────────────────────────────────────────────────

const Spoiler = Node.create({
    
name: 'spoiler',
    
group: 'block',
    
content: 'block+',
    
defining: true,
    
addAttributes() {
        return {
            
title: { default: 'Spoiler' },
            
open:  { default: true },
        }
    },
    
parseHTML() {
        return [{
            
tag: 'details.spoiler',
            
getAttrs: el => ({
                
title: el.querySelector(':scope > summary')?.textContent?.trim() || 'Spoiler',
                
open: true,
            }),
            
contentElement: el => el.querySelector(':scope > div') || el,
        }]
    },
    
renderHTML({ node }) {
        
// open не сохраняем в HTML — для читателей спойлер закрыт по умолчанию
        
return ['details', { class: 'spoiler' },
            [
'summary', {}, node.attrs.title],
            [
'div', {}, 0]]
    },
    
addNodeView() {
        return ({ 
node: initNode, getPos, editor }) => {
            
let attrs = { ...initNode.attrs }

            const 
dom = document.createElement('details')
            
dom.className = 'spoiler'
            
dom.open = attrs.open

            
const summary = document.createElement('summary')
            
summary.setAttribute('contenteditable', 'false')
            
summary.textContent = attrs.title || 'Spoiler'

            
summary.addEventListener('mousedown', e => {
                
e.preventDefault()
                
e.stopPropagation()
                const 
pos = typeof getPos === 'function' ? getPos() : null
                
if (pos !== null) {
                    
editor.view.dispatch(
                        
editor.view.state.tr.setNodeMarkup(pos, null, { ...attrs, open: !attrs.open })
                    )
                }
            })

            const 
contentDOM = document.createElement('div')
            
dom.appendChild(summary)
            
dom.appendChild(contentDOM)

            return {
                
dom,
                
contentDOM,
                
update(updatedNode) {
                    if (
updatedNode.type !== initNode.type) return false
                    attrs 
= { ...updatedNode.attrs }
                    
summary.textContent = attrs.title || 'Spoiler'
                    
dom.open = attrs.open
                    
return true
                
},
            }
        }
    },
    
addCommands() {
        return {
            
insertSpoiler: title => ({ commands }) => commands.insertContent({
                
type: this.name, attrs: { title, open: true }, content: [{ type: 'paragraph' }],
            }),
        }
    },
})

// ─── Hide ─────────────────────────────────────────────────────────────────────

const Hide = Node.create({
    
name: 'hide',
    
group: 'block',
    
content: 'block+',
    
defining: true,
    
parseHTML() { return [{ tag: 'div.hidden' }] },
    
renderHTML() { return ['div', { class: 'hidden' }, 0] },
    
addCommands() {
        return {
            
insertHide: () => ({ commands }) => commands.insertContent({
                
type: this.name, content: [{ type: 'paragraph' }],
            }),
        }
    },
})

// ─── Utils ────────────────────────────────────────────────────────────────────

function rgbToHex(html) {
    return 
html.replace(/rgb((d+),s*(d+),s*(d+))/g, (_, r, g, b) =>
        
'#' + [r, g, b].map(x => parseInt(x).toString(16).padStart(2, '0')).join('')
    )
}

// Когда clearNodes() конвертирует code block в параграф, переносы строк
// становятся буквальными n внутри <p>, которые браузер схлопывает в пробел.
// Нормализуем их в <br>.
function fixNewlines(html) {
    return 
html.replace(/(<pb[^>]*>)([sS]*?)(</p>)/g, (_, open, content, close) =>
        
open + content.replace(/n/g, '<br>') + close
    
)
}

function 
validateUrl(url) {
    if (!
url) return false
    
if (!/^https?:///i.test(url)) {
        
alert(__('editor.invalid_url'))
        return 
false
    
}
    return 
true
}

function 
positionDropdown(btn, menu) {
    const 
rect = btn.getBoundingClientRect()
    
menu.style.top  = (rect.bottom + 4) + 'px'
    
menu.style.left = rect.left + 'px'
    
requestAnimationFrame(() => {
        const 
overflow = menu.getBoundingClientRect().right - window.innerWidth + 8
        
if (overflow > 0) menu.style.left = Math.max(8, parseFloat(menu.style.left) - overflow) + 'px'
    
})
}

// ─── Toolbar ──────────────────────────────────────────────────────────────────

const COLORS = [
    { 
color: '#6b7280' },
    { 
color: '#f59e0b' },
    { 
color: '#f97316' },
    { 
color: '#ef4444' },
    { 
color: '#3b82f6' },
    { 
color: '#8b5cf6' },
    { 
color: '#22c55e' },
    { 
color: '#ec4899' },
    { 
color: '#06b6d4' },
]

const 
BG_COLORS = [
    { 
color: '#6b7280' },
    { 
color: '#ca8a04' },
    { 
color: '#ea580c' },
    { 
color: '#dc2626' },
    { 
color: '#2563eb' },
    { 
color: '#7c3aed' },
    { 
color: '#16a34a' },
    { 
color: '#db2777' },
    { 
color: '#0891b2' },
]

const 
SIZES = [
    { 
get label() { return __('editor.size_xs') }, value: '0.7em'  },
    { 
get label() { return __('editor.size_sm') }, value: '0.85em' },
    { 
get label() { return __('editor.size_md') }, value: null      },
    { 
get label() { return __('editor.size_lg') }, value: '1.3em'  },
    { 
get label() { return __('editor.size_xl') }, value: '1.6em'  },
]

document.addEventListener('click', () => {
    
document.querySelectorAll('.tiptap-dropdown-menu.is-open')
        .forEach(
m => m.classList.remove('is-open'))
})

document.addEventListener('scroll', (e) => {
    
document.querySelectorAll('.tiptap-dropdown-menu.is-open').forEach(m => {
        if (
m.contains(e.target)) return  // скролл внутри самого меню — не трогаем позицию
        
if (m._reposition) m._reposition()
        else if (
m._anchorBtn) positionDropdown(m._anchorBtn, m)
    })
}, 
true)

// ─── Sticker picker ───────────────────────────────────────────────────────────

let stickerCache = null

async 
function getStickerData() {
    if (
stickerCache) return stickerCache
    
try {
        const 
resp = await fetch('/ajax/getstickers')
        
stickerCache = await resp.json() // [{name: '/uploads/...', code: ':D'}, ...]
    
} catch {
        
stickerCache = []
    }
    return 
stickerCache
}

function 
makeStickerPicker(editor) {
    const 
wrap = document.createElement('div')
    
wrap.className = 'tiptap-dropdown'

    
const btn = document.createElement('button')
    
btn.type = 'button'
    
btn.className = 'tiptap-btn'
    
btn.title = __('editor.sticker')
    
btn.innerHTML = '<i class="fas fa-smile"></i><i class="fas fa-chevron-down tiptap-dd-arrow"></i>'

    
const panel = document.createElement('div')
    
panel.className = 'tiptap-dropdown-menu tiptap-sticker-panel'
    
document.body.appendChild(panel)

    
let loaded = false

    
function renderStickers(stickers, grid) {
        
grid.innerHTML = ''
        
stickers.forEach(({ name, code }) => {
            const 
img = document.createElement('img')
            
img.src = name
            img
.alt = code
            img
.title = code
            img
.addEventListener('mousedown', e => {
                
e.preventDefault()
                
e.stopPropagation()
                
editor.chain().focus().insertSticker({ src: name, alt: code }).run()
                
editor.commands.insertContent(' ')
                
panel.classList.remove('is-open')
            })
            
grid.appendChild(img)
        })
    }

    
async function openPanel() {
        if (!
loaded) {
            
panel.innerHTML = '<span class="tiptap-sticker-loading">...</span>'
            
panel.classList.add('is-open')
            
positionPanel()

            const 
categories = await getStickerData()
            
panel.innerHTML = ''

            
if (!categories.length) return

            const 
tabs = document.createElement('div')
            
tabs.className = 'tiptap-sticker-tabs'

            
const grid = document.createElement('div')
            
grid.className = 'tiptap-sticker-grid'

            
categories.forEach((cat, i) => {
                const 
tab = document.createElement('button')
                
tab.type = 'button'
                
tab.className = 'tiptap-sticker-tab' + (i === 0 ? ' is-active' : '')
                
tab.textContent = cat.name
                tab
.addEventListener('mousedown', e => e.preventDefault())
                
tab.addEventListener('click', e => {
                    
e.stopPropagation()
                    
tabs.querySelectorAll('.tiptap-sticker-tab').forEach(t => t.classList.remove('is-active'))
                    
tab.classList.add('is-active')
                    
renderStickers(cat.stickers, grid)
                })
                
tabs.appendChild(tab)
            })

            
panel.appendChild(tabs)
            
panel.appendChild(grid)
            
renderStickers(categories[0].stickers, grid)
            
loaded = true
            positionPanel
()
        } else {
            
positionPanel()
            
panel.classList.add('is-open')
        }
    }

    function 
positionPanel() {
        const 
vw   = window.innerWidth
        
const rect = btn.getBoundingClientRect()
        
// Горизонталь: ограничиваем ширину экраном и прижимаем влево если вылезает
        
const panelWidth = Math.min(360, vw - 16)
        
panel.style.width = panelWidth + 'px'
        
panel.style.left  = Math.max(8, Math.min(rect.left, vw - panelWidth - 8)) + 'px'
        
// Вертикаль: вниз или вверх в зависимости от места
        
const spaceBelow = window.innerHeight - rect.bottom
        
if (spaceBelow < 300 && rect.top > 300) {
            
panel.style.top    = 'auto'
            
panel.style.bottom = (window.innerHeight - rect.top + 4) + 'px'
        
} else {
            
panel.style.top    = (rect.bottom + 4) + 'px'
            
panel.style.bottom = 'auto'
        
}
    }

    
btn.addEventListener('mousedown', e => e.preventDefault())
    
btn.addEventListener('click', e => {
        
e.stopPropagation()
        const 
wasOpen = panel.classList.contains('is-open')
        
document.querySelectorAll('.tiptap-dropdown-menu.is-open')
            .forEach(
m => m.classList.remove('is-open'))
        if (!
wasOpen) { panel._anchorBtn = btn; panel._reposition = positionPanel; openPanel() }
    })

    
wrap.appendChild(btn)
    return 
wrap
}

function 
makeDropdown(icon, title, items, extraClass = '') {
    const 
wrap = document.createElement('div')
    
wrap.className = 'tiptap-dropdown'

    
const btn = document.createElement('button')
    
btn.type = 'button'
    
btn.className = 'tiptap-btn'
    
btn.title = title
    btn
.innerHTML = `<i class="fas ${icon}"></i><i class="fas fa-chevron-down tiptap-dd-arrow"></i>`

    const 
menu = document.createElement('div')
    
menu.className = 'tiptap-dropdown-menu' + (extraClass ? ' ' + extraClass : '')
    
items.forEach(item => menu.appendChild(item))
    
document.body.appendChild(menu)

    function 
positionMenu() { positionDropdown(btn, menu) }

    
btn.addEventListener('mousedown', e => e.preventDefault())
    
btn.addEventListener('click', e => {
        
e.stopPropagation()
        const 
wasOpen = menu.classList.contains('is-open')
        
document.querySelectorAll('.tiptap-dropdown-menu.is-open').forEach(m => m.classList.remove('is-open'))
        if (!
wasOpen) { positionMenu(); menu._anchorBtn = btn; menu.classList.add('is-open') }
    })

    
wrap.appendChild(btn)
    
wrap._dropdownBtn = btn
    
return wrap
}

// ─── Mention suggestion ───────────────────────────────────────────────────────

const suggestion = {
    
char: '@',
    
minLength: 2,

    
items: async ({ query }) => {
        if (
query.length < 2) return []
        const 
res = await fetch('/search-users?query=' + encodeURIComponent(query))
        return 
res.ok ? await res.json() : []
    },

    
render: () => {
        
let el, selectedIndex = 0, items = [], command

        
function render() {
            
el.innerHTML = ''
            
el.style.display = items.length ? 'block' : 'none'
            
items.forEach((item, i) => {
                const 
div = document.createElement('div')
                
div.className = 'mention-item' + (i === selectedIndex ? ' mention-item-selected' : '')
                
div.textContent = '@' + item.login + (item.name && item.name !== item.login ? ' — ' + item.name : '')
                
div.addEventListener('mousedown', e => {
                    
e.preventDefault()
                    
command(item)
                })
                
el.appendChild(div)
            })
        }

        return {
            
onStart(props) {
                
command = props.command
                items 
= props.items
                selectedIndex 
= 0

                el 
= document.createElement('div')
                
el.className = 'mention-dropdown'
                
el.style.display = 'none'
                
document.body.appendChild(el)

                const 
rect = props.clientRect()
                
el.style.position = 'fixed'
                
el.style.top = rect.bottom + 'px'
                
el.style.left = rect.left + 'px'

                
render()
            },

            
onUpdate(props) {
                
command = props.command
                items 
= props.items
                selectedIndex 
= 0

                
const rect = props.clientRect()
                
el.style.top = rect.bottom + 'px'
                
el.style.left = rect.left + 'px'

                
render()
            },

            
onKeyDown(props) {
                if (
props.event.key === 'ArrowDown') {
                    
selectedIndex = (selectedIndex + 1) % items.length
                    render
()
                    return 
true
                
}
                if (
props.event.key === 'ArrowUp') {
                    
selectedIndex = (selectedIndex - 1 + items.length) % items.length
                    render
()
                    return 
true
                
}
                if (
props.event.key === 'Enter') {
                    if (
items[selectedIndex]) command(items[selectedIndex])
                    return 
true
                
}
                return 
false
            
},

            
onExit() {
                
el?.remove()
            },
        }
    },

    
command({ editor, range, props }) {
        
editor.chain().focus().deleteRange(range).insertContent({
            
type: 'mention',
            
attrs: { id: props.login, label: props.login },
        }).
insertContent(' ').run()
    },
}

function 
buildToolbar(editor, textarea, uploadImageFn) {
    const 
bar = document.createElement('div')
    
bar.className = 'tiptap-toolbar'

    
const activeButtons = []

    function 
btn(icon, title, action, getActive = null) {
        const 
el = document.createElement('button')
        
el.type = 'button'
        
el.className = 'tiptap-btn'
        
el.title = title
        el
.innerHTML = `<i class="fas ${icon}"></i>`
        
el.addEventListener('mousedown', e => { e.preventDefault(); action() })
        if (
getActive) activeButtons.push({ el, getActive })
        
bar.appendChild(el)
        return 
el
    
}

    function 
sep() {
        const 
el = document.createElement('span')
        
el.className = 'tiptap-sep'
        
bar.appendChild(el)
    }

    function 
dropdown(el) { bar.appendChild(el) }

    
btn('fa-bold',          __('editor.bold'),      () => editor.chain().focus().toggleBold().run(),      () => editor.isActive('bold'))
    
btn('fa-italic',        __('editor.italic'),    () => editor.chain().focus().toggleItalic().run(),    () => editor.isActive('italic'))
    
btn('fa-underline',     __('editor.underline'), () => editor.chain().focus().toggleUnderline().run(), () => editor.isActive('underline'))
    
btn('fa-strikethrough', __('editor.strike'),    () => editor.chain().focus().toggleStrike().run(),   () => editor.isActive('strike'))
    
sep()

    const 
resetSwatch = document.createElement('button')
    
resetSwatch.type = 'button'
    
resetSwatch.className = 'tiptap-color-swatch tiptap-color-reset'
    
resetSwatch.title = __('editor.reset_color')
    
resetSwatch.addEventListener('mousedown', e => { e.preventDefault(); editor.chain().focus().unsetColor().run() })

    const 
customColorInput = document.createElement('input')
    
customColorInput.type = 'color'
    
customColorInput.title = __('editor.custom_color')
    
customColorInput.className = 'tiptap-color-custom'
    
customColorInput.addEventListener('input', () => {
        
editor.chain().focus().setColor(customColorInput.value).run()
    })

    const 
colorSwatches = [...COLORS.map(({ color }) => {
        const 
el = document.createElement('button')
        
el.type = 'button'
        
el.className = 'tiptap-color-swatch'
        
el.title = color
        el
.style.background = color
        el
.addEventListener('mousedown', e => { e.preventDefault(); editor.chain().focus().setColor(color).run() })
        return 
el
    
}), customColorInput, resetSwatch]
    const 
colorDd = makeDropdown('fa-palette', __('editor.color'), colorSwatches, 'tiptap-colors-menu')
    
dropdown(colorDd)
    
activeButtons.push({ el: colorDd._dropdownBtn, getActive: () => !!editor.getAttributes('textStyle').color })

    const 
resetBgSwatch = document.createElement('button')
    
resetBgSwatch.type = 'button'
    
resetBgSwatch.className = 'tiptap-color-swatch tiptap-color-reset'
    
resetBgSwatch.title = __('editor.reset_bg')
    
resetBgSwatch.addEventListener('mousedown', e => { e.preventDefault(); editor.chain().focus().unsetHighlight().run() })

    const 
customBgInput = document.createElement('input')
    
customBgInput.type = 'color'
    
customBgInput.title = __('editor.custom_bg')
    
customBgInput.className = 'tiptap-color-custom'
    
customBgInput.addEventListener('input', () => {
        
editor.chain().focus().setHighlight({ color: customBgInput.value }).run()
    })

    const 
bgSwatches = [...BG_COLORS.map(({ color }) => {
        const 
el = document.createElement('button')
        
el.type = 'button'
        
el.className = 'tiptap-color-swatch'
        
el.title = color
        el
.style.background = color
        el
.addEventListener('mousedown', e => { e.preventDefault(); editor.chain().focus().setHighlight({ color }).run() })
        return 
el
    
}), customBgInput, resetBgSwatch]
    const 
bgDd = makeDropdown('fa-fill-drip', __('editor.bg_color'), bgSwatches, 'tiptap-colors-menu')
    
dropdown(bgDd)
    
activeButtons.push({ el: bgDd._dropdownBtn, getActive: () => !!editor.getAttributes('textStyle').backgroundColor })

    const 
sizeItems = SIZES.map(({ label, value }) => {
        const 
el = document.createElement('button')
        
el.type = 'button'
        
el.className = 'tiptap-size-item'
        
el.textContent = label
        el
.addEventListener('mousedown', e => {
            
e.preventDefault()
            
value ? editor.chain().focus().setFontSize(value).run()
                  : 
editor.chain().focus().unsetFontSize().run()
        })
        return 
el
    
})
    const 
sizeDd = makeDropdown('fa-font', __('editor.font_size'), sizeItems)
    
dropdown(sizeDd)
    
activeButtons.push({ el: sizeDd._dropdownBtn, getActive: () => !!editor.getAttributes('textStyle').fontSize })
    
sep()

    const 
alignItems = [
        { 
icon: 'fa-align-left',   title: __('editor.align_left'),   align: 'left'   },
        { 
icon: 'fa-align-center', title: __('editor.align_center'), align: 'center' },
        { 
icon: 'fa-align-right',  title: __('editor.align_right'),  align: 'right'  },
    ].
map(({ icon, title, align }) => {
        const 
el = document.createElement('button')
        
el.type = 'button'
        
el.className = 'tiptap-menu-item'
        
el.innerHTML = `<i class="fas ${icon}"></i> ${title}`
        
el.addEventListener('mousedown', e => { e.preventDefault(); editor.chain().focus().setTextAlign(align).run() })
        return 
el
    
})
    const 
alignDd = makeDropdown('fa-align-left', __('editor.alignment'), alignItems)
    
dropdown(alignDd)
    
activeButtons.push({ el: alignDd._dropdownBtn, getActive: () =>
        
editor.isActive({ textAlign: 'center' }) || editor.isActive({ textAlign: 'right' })
    })

    const 
listItems = [
        { 
icon: 'fa-list-ul', title: __('editor.bullet_list'),  action: () => editor.chain().focus().toggleBulletList().run()  },
        { 
icon: 'fa-list-ol', title: __('editor.ordered_list'), action: () => editor.chain().focus().toggleOrderedList().run() },
    ].
map(({ icon, title, action }) => {
        const 
el = document.createElement('button')
        
el.type = 'button'
        
el.className = 'tiptap-menu-item'
        
el.innerHTML = `<i class="fas ${icon}"></i> ${title}`
        
el.addEventListener('mousedown', e => { e.preventDefault(); action() })
        return 
el
    
})
    const 
listDd = makeDropdown('fa-list-ul', __('editor.lists'), listItems)
    
dropdown(listDd)
    
activeButtons.push({ el: listDd._dropdownBtn, getActive: () =>
        
editor.isActive('bulletList') || editor.isActive('orderedList')
    })

    function 
makeMenuSep() {
        const 
el = document.createElement('hr')
        
el.className = 'tiptap-menu-sep'
        
return el
    
}

    function 
tableMenuItem(icon, title, action) {
        const 
el = document.createElement('button')
        
el.type = 'button'
        
el.className = 'tiptap-menu-item'
        
el.innerHTML = `<i class="fas ${icon}"></i> ${title}`
        
el.addEventListener('mousedown', e => { e.preventDefault(); action() })
        return 
el
    
}

    const 
tableMenuItems = [
        
tableMenuItem('fa-table',          __('editor.table_insert'),         () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()),
        
makeMenuSep(),
        
tableMenuItem('fa-arrow-up',       __('editor.table_row_before'),     () => editor.chain().focus().addRowBefore().run()),
        
tableMenuItem('fa-arrow-down',     __('editor.table_row_after'),      () => editor.chain().focus().addRowAfter().run()),
        
tableMenuItem('fa-trash-alt',      __('editor.table_row_delete'),     () => editor.chain().focus().deleteRow().run()),
        
makeMenuSep(),
        
tableMenuItem('fa-arrow-left',     __('editor.table_col_before'),     () => editor.chain().focus().addColumnBefore().run()),
        
tableMenuItem('fa-arrow-right',    __('editor.table_col_after'),      () => editor.chain().focus().addColumnAfter().run()),
        
tableMenuItem('fa-trash-alt',      __('editor.table_col_delete'),     () => editor.chain().focus().deleteColumn().run()),
        
makeMenuSep(),
        
tableMenuItem('fa-times-circle',   __('editor.table_delete'),         () => editor.chain().focus().deleteTable().run()),
    ]
    const 
tableDd = makeDropdown('fa-table', __('editor.table'), tableMenuItems)
    
dropdown(tableDd)
    
activeButtons.push({ el: tableDd._dropdownBtn, getActive: () => editor.isActive('table') })
    
sep()

    const 
linkBtn = btn('fa-link', __('editor.link'), () => {
        const 
existing = editor.getAttributes('link').href || ''
        
const { from, to } = editor.state.selection
        
const selected = editor.state.doc.textBetween(from, to, '')
        const 
url = prompt(__('editor.url_link') + ':', existing || selected)
        if (!
validateUrl(url)) return
        if (
selected || existing) {
            
editor.chain().focus().extendMarkRange('link').setLink({ href: url, target: null }).run()
        } else {
            
editor.chain().focus()
                .
insertContent(`<a href="${url}">${url}</a>`)
                .
run()
        }
    }, () => 
editor.isActive('link'))

    
btn('fa-image', __('editor.image'), async () => {
        const 
url = prompt(__('editor.url_image') + ':')
        if (!
validateUrl(url)) return

        const 
imagePattern = /.(jpe?g|png|gif|webp|bmp|svg)(?.*)?$/i
        
if (imagePattern.test(url)) {
            
editor.chain().focus().setImage({ src: url }).run()
            return
        }

        const 
data = await fetch('/ajax/resolve-image?url=' + encodeURIComponent(url)).then(r => r.json())
        if (
data.image) {
            
editor.chain().focus().setImage({ src: data.image }).run()
        } else {
            
editor.chain().focus().setImage({ src: url }).run()
            
notyf.warning(__('editor.image_not_found'))
        }
    })

    
// Кнопка загрузки файла (только если есть data-relate-type)
    
if (textarea.dataset.relateType) {
        
btn('fa-cloud-arrow-up', __('editor.upload_image'), () => {
            const 
input = document.createElement('input')
            
input.type = 'file'
            
input.accept = 'image/*,video/*'
            
input.onchange = async () => {
                const 
file = input.files[0]
                if (!
file) return
                
await uploadImageFn(editor, file)
            }
            
input.click()
        })
    }

    
btn('fa-play-circle', __('editor.video'), () => {
        const 
url = prompt(__('editor.url_video') + ':')
        if (!
validateUrl(url)) return
        
editor.chain().focus().insertVideo(url).run()
    })

    
btn('fa-music', __('editor.audio'), () => {
        const 
url = prompt(__('editor.url_audio') + ':')
        if (!
validateUrl(url)) return
        
editor.chain().focus().insertAudio(url).run()
    })
    
sep()

    
btn('fa-plus-square', __('editor.spoiler'), () => {
        if (
editor.isActive('spoiler')) {
            
editor.chain().focus().lift('spoiler').run()
        } else {
            const 
title = prompt(__('editor.spoiler_title') + ':', __('editor.spoiler'))
            if (
title !== null) editor.chain().focus().insertSpoiler(title || __('editor.spoiler')).run()
        }
    }, () => 
editor.isActive('spoiler'))
    
btn('fa-eye-slash', __('editor.hide'),
        () => 
editor.isActive('hide')
            ? 
editor.chain().focus().lift('hide').run()
            : 
editor.chain().focus().insertHide().run(),
        () => 
editor.isActive('hide'))
    
btn('fa-quote-right', __('editor.quote'), () => {
        if (
editor.isActive('blockquote')) {
            
editor.chain().focus().toggleBlockquote().run()
        } else {
            const 
author = prompt(__('editor.quote_author') + ':')
            if (
author !== null) editor.chain().focus().toggleBlockquote(author || null).run()
        }
    }, () => 
editor.isActive('blockquote'))
    
btn('fa-code', __('editor.code_block'),
        () => {
            if (
editor.isActive('codeBlock')) {
                
editor.chain().focus().toggleCodeBlock().run()
            } else {
                const { 
from, to } = editor.state.selection
                
const text = editor.state.doc.textBetween(from, to, 'n', 'n')
                
editor.chain().focus()
                    .
deleteSelection()
                    .
insertContentAt(editor.state.selection.from, { type: 'codeBlock', content: text ? [{ type: 'text', text }] : [] })
                    .
run()
            }
        },
        () => 
editor.isActive('codeBlock'))
    
sep()

    
dropdown(makeStickerPicker(editor))
    
sep()

    
btn('fa-eraser', __('editor.clear_format'),
        () => 
editor.chain().focus().unsetAllMarks().clearNodes().run())

    function 
updateActive() {
        
activeButtons.forEach(({ el, getActive }) => el.classList.toggle('is-active', getActive()))
    }
    
editor.on('selectionUpdate', updateActive)
    
editor.on('transaction', updateActive)

    return { 
bar, linkBtn }
}

// ─── Link bubble ──────────────────────────────────────────────────────────────

function makeEditorBubble(editor, linkBtn) {
    const 
bubble = document.createElement('div')
    
bubble.className = 'tiptap-link-bubble'
    
bubble.style.display = 'none'
    
document.body.appendChild(bubble)

    const 
input = document.createElement('input')
    
input.type = 'url'
    
input.className = 'tiptap-link-bubble-input'
    
input.placeholder = 'https://'

    
function applyUrl() {
        const 
url = input.value.trim()
        if (!
url) { input.value = editor.getAttributes('link').href || ''; return }
        if (!
validateUrl(url)) { input.value = editor.getAttributes('link').href || ''; return }
        
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
    }

    
input.addEventListener('keydown', e => {
        if (
e.key === 'Enter')  { e.preventDefault(); applyUrl() }
        if (
e.key === 'Escape') { e.preventDefault(); input.value = editor.getAttributes('link').href || ''; editor.commands.focus() }
    })
    
input.addEventListener('mousedown', e => e.stopPropagation())
    
input.addEventListener('blur', applyUrl)

    function 
makeBtn(icon, title, action) {
        const 
b = document.createElement('button')
        
b.type = 'button'
        
b.title = title
        b
.innerHTML = `<i class="fas ${icon}"></i>`
        
b.addEventListener('mousedown', e => { e.preventDefault(); action() })
        return 
b
    
}

    
bubble.appendChild(input)
    
bubble.appendChild(makeBtn('fa-external-link-alt', __('editor.link_open'), () => {
        const 
href = editor.getAttributes('link').href
        
if (href) window.open(href, '_blank', 'noopener,noreferrer')
    }))
    
bubble.appendChild(makeBtn('fa-link-slash', __('editor.link_remove'), () => {
        
editor.chain().focus().extendMarkRange('link').unsetLink().run()
    }))

    
let hideTimer = null

    
function show() {
        
clearTimeout(hideTimer)
        if (
document.activeElement !== input) {
            
input.value = editor.getAttributes('link').href || ''
        
}

        const 
rect = linkBtn.getBoundingClientRect()
        
bubble.style.display = 'flex'
        
bubble.style.top  = (rect.bottom + window.scrollY + 4) + 'px'
        
bubble.style.left = '0px'

        
requestAnimationFrame(() => {
            const 
bw = bubble.getBoundingClientRect().width
            let left 
= rect.left + window.scrollX + rect.width / 2 - bw / 2
            
const overflow = left + bw - window.innerWidth + 8
            
if (overflow > 0) left -= overflow
            bubble
.style.left = Math.max(8, left) + 'px'
        
})
    }

    function 
hide() { bubble.style.display = 'none' }

    function 
update() {
        if (
editor.isActive('link') && editor.state.selection.empty) show()
        else 
hide()
    }

    
editor.on('selectionUpdate', update)
    
editor.on('blur', ({ event }) => {
        if (
bubble.contains(event?.relatedTarget)) return
        
hideTimer = setTimeout(hide, 150)
    })
    
editor.on('focus', () => { clearTimeout(hideTimer); update() })
    
document.addEventListener('scroll', () => { if (bubble.style.display !== 'none') update() }, true)
}

// ─── Init ─────────────────────────────────────────────────────────────────────

function initEditor(textarea) {
    const 
maxLength   = parseInt(textarea.getAttribute('maxlength')) || null
    
const placeholder = textarea.getAttribute('placeholder') || ''
    
const wasRequired = textarea.hasAttribute('required')

    const 
wrapper = document.createElement('div')
    
wrapper.className = 'tiptap-wrapper'
    
textarea.parentNode.insertBefore(wrapper, textarea)

    const 
editorEl = document.createElement('div')
    
editorEl.className = 'tiptap-editor-content'
    
const rows = parseInt(textarea.getAttribute('rows'))
    if (
rows) {
        const 
height = rows * 24 + 22
        editorEl
.style.minHeight = height + 'px'
        
editorEl.style.height = height + 'px'
    
}
    
wrapper.appendChild(editorEl)

    
textarea.style.display = 'none'
    
textarea.removeAttribute('required')
    
textarea.removeAttribute('maxlength') // maxlength по HTML бессмысленен — считаем текст

    
if (textarea.closest('.is-invalid')) {
        
wrapper.classList.add('is-invalid')
    }

    
let isChanged = false
    
// Плагины могут диспатчить нормализующую транзакцию ещё во время new Editor(),
    // тогда onUpdate срабатывает до инициализации editor/counterEl ниже (TDZ).
    // Считаем редактор готовым только после полной настройки.
    
let ready = false

    
// === Image Upload Helper ===
    
async function uploadImage(editor, file, pos = null) {
        
// Проверка типа файла
        
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
            
notyf.error(__('editor.upload_failed') + ': неподдерживаемый формат')
            return
        }

        const 
formData = new FormData()
        
formData.append('file', file)

        const 
type = textarea.dataset.relateType || null
        
const id = textarea.dataset.relateId || 0
        
if (type) formData.append('type', type)
        if (
id) formData.append('id', id)

        const 
csrfToken = document.querySelector('meta[name="csrf-token"]')?.content

        
try {
            const 
response = await fetch('/ajax/file/upload', {
                
method: 'POST',
                
body: formData,
                
headers: { 'X-CSRF-TOKEN': csrfToken }
            })

            const 
data = await response.json()

            if (
data.success && data.path) {
                const 
isVideo = data.type === 'video'
                
const src = data.source || data.path

                
if (isVideo) {
                    
editor.chain().focus().insertContentAt(pos ?? editor.state.selection.from, {
                        
type: 'video',
                        
attrs: { src },
                    }).
run()
                } else {
                    
editor.chain().focus().insertContentAt(pos ?? editor.state.selection.from, {
                        
type: 'image',
                        
attrs: { src },
                    }).
run()
                }

                const 
scope = textarea.closest('form') ?? document
                
const templateEl = scope.querySelector('.js-image-template')
                const 
template = templateEl?.cloneNode(true)
                if (
template) {
                    if (
isVideo) {
                        const 
img = template.querySelector('img')
                        if (
img) {
                            const 
video = document.createElement('video')
                            
video.src = data.path
                            video
.className = img.className
                            video
.preload = 'metadata'
                            
const imgParent = img.parentElement
                            img
.replaceWith(video)
                            
imgParent?.insertAdjacentHTML('beforeend', '<span class="slide-play-icon">▶</span>')
                        }
                    } else {
                        
template.querySelector('img')?.setAttribute('src', data.path)
                    }
                    
template.querySelector('a')?.setAttribute('data-id', data.id)
                    
scope.querySelector('.js-files')?.insertAdjacentHTML('beforeend', template.innerHTML)
                }
            } else {
                
notyf.error(data.message || __('editor.upload_failed'))
            }
        } catch (
error) {
            
notyf.error(__('editor.upload_error'))
        }
    }
    
// ============================

    
const editor = new Editor({
        
element: editorEl,
        
editorProps: {
            
transformPastedText(text) {
                return 
text
                    
.split('n')
                    .
map(line => line.trimEnd())
                    .
join('n')
                    .
replace(/n{3,}/g, 'nn')
            },
            
transformPastedHTML(html) {
                const 
doc = new DOMParser().parseFromString(html, 'text/html')
                
doc.querySelectorAll('*').forEach(el => {
                    
el.removeAttribute('class')
                    
el.removeAttribute('style')
                })
                
// Убираем последовательные пустые параграфы
                
let prevEmpty = false
                doc
.body.querySelectorAll('p').forEach(p => {
                    const 
isEmpty = !p.textContent.trim() && !p.querySelector('img,audio,video,iframe')
                    if (
isEmpty && prevEmpty) p.remove()
                    
prevEmpty = isEmpty
                
})
                return 
doc.body.innerHTML
            
},
        },
        
extensions: [
            
StarterKit.configure({
                
blockquote: false,
                
codeBlock: { HTMLAttributes: { class: 'code' } },
                
link: false,
            }),
            
CustomLink.configure({
                
openOnClick: false,
                
autolink: true,
                
linkOnPaste: true,
                
shouldAutoLink: url => !/[<>"'`]/.test(decodeURIComponent(url)),
                HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer nofollow' },
            }),
            HardBreakScroll,
            Blockquote,
            TextStyle,
            Color,
            BackgroundColor,
            FontSize,
            TextAlign.configure({ types: ['heading', 'paragraph'] }),
            Image.configure({ inline: false, HTMLAttributes: { class: 'image' }, allowBase64: false }),
            FileHandler.configure({
                allowedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp', 'image/svg+xml', 'video/mp4', 'video/webm'],
                onDrop: async (editor, files, pos) => {
                    for (const file of files) {
                        await uploadImage(editor, file, pos)
                    }
                },
                onPaste: async (editor, files, htmlContent) => {
                    for (const file of files) {
                        await uploadImage(editor, file)
                    }
                },
            }),
            Placeholder.configure({ placeholder }),
            VideoFile,
            VideoEmbed,
            AudioEmbed,
            Spoiler,
            Hide,
            Sticker,
            Table.configure({ resizable: false, HTMLAttributes: { class: 'table' } }),
            TableRow,
            TableHeader,
            TableCell,
            CharacterCount,
            Mention.extend({
                addAttributes() {
                    return {
                        id:    { default: null, parseHTML: el => el.getAttribute('href')?.split('/').pop() || null },
                        label: { default: null, parseHTML: el => el.textContent.replace(/^@/, '').trim() || null },
                    }
                },
                parseHTML() {
                    return [{ tag: 'a.user[href]', priority: 1100 }]
                },
            }).configure({
                HTMLAttributes: { class: 'user' },
                renderHTML({ options, node }) {
                    return ['a', mergeAttributes(options.HTMLAttributes, { href: '/users/' + node.attrs.id }), '@' + (node.attrs.label || node.attrs.id)]
                },
                suggestion,
            }),
        ],
        content: textarea.value || '',
        onUpdate({ editor }) {
            textarea.value = fixNewlines(rgbToHex(editor.getHTML()))
            if (ready) {
                isChanged = true
                updateCounter()
            }
        },
        onCreate({ editor }) {
            textarea.value = fixNewlines(rgbToHex(editor.getHTML()))
            // Если документ начинается или заканчивается атомарным узлом —
            // добавляем пустые параграфы, иначе некуда поставить курсор
            const { state } = editor
            const { schema } = state
            let tr = state.tr
            let modified = false
            if (tr.doc.firstChild?.isAtom) {
                tr = tr.insert(0, schema.nodes.paragraph.create())
                modified = true
            }
            if (tr.doc.lastChild?.isAtom) {
                tr = tr.insert(tr.doc.content.size, schema.nodes.paragraph.create())
                modified = true
            }
            if (modified) {
                editor.view.dispatch(tr)
                isChanged = false
            }
        },
    })

    editor.resetChanged  = () => { isChanged = false }
    editor.getIsChanged  = () => isChanged



    window.addEventListener('beforeunload', e => {
        if (isChanged && !editor.isEmpty) { e.preventDefault(); return e.returnValue = '' }
    })
    textarea.closest('form')?.addEventListener('submit', () => {
        isChanged = false
        textarea.value = textarea.value
            .replace(/(<p></p>)+/g, '<p></p>')  // схлопываем несколько пустых p в один
            .replace(/^(<p></p>)+/, '')           // убираем пустые p в начале
            .replace(/(<p></p>)+$/, '')           // убираем пустые p в конце
    })

    // Клик по пустой области контейнера (ниже контента) ставит курсор в конец
    editorEl.addEventListener('click', e => {
        if (e.target === editorEl) editor.commands.focus('end')
    })


    window._tiptapActiveEditor = editor
    editor.on('focus', () => { window._tiptapActiveEditor = editor })

    if (textarea.id) {
        window._tiptapEditors = window._tiptapEditors || {}
        window._tiptapEditors[textarea.id] = editor
    }

    const { bar: toolbar, linkBtn } = buildToolbar(editor, textarea, uploadImage)
    wrapper.insertBefore(toolbar, editorEl)
    makeEditorBubble(editor, linkBtn)

    function getCharCount() {
        let count = editor.storage.characterCount.characters()
        editor.state.doc.descendants(node => {
            if (node.type.name === 'spoiler') count += (node.attrs.title || '').length
        })
        return count
    }

    const counterEl = textarea.parentNode.querySelector('.js-textarea-counter')
    function updateCounter() {
        if (!counterEl) return
        const len = getCharCount()
        if (maxLength) {
            const remaining = maxLength - len
            counterEl.textContent = len === 0 ? '' : __('characters_left') + ': ' + remaining
            counterEl.classList.toggle('text-danger', remaining < 0)
        } else {
            counterEl.textContent = len || ''
        }
    }
    updateCounter()
    ready = true

    if (wasRequired) {
        const form = textarea.closest('form')
        if (form) {
            form.addEventListener('submit', e => {
                if (getCharCount() === 0) {
                    e.preventDefault()
                    wrapper.classList.add('is-invalid')
                    wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' })
                    notyf.error(__('validator.empty_field'))
                } else {
                    wrapper.classList.remove('is-invalid')
                }
            }, { capture: true })
        }
    }

    return editor
}

// ─── Export ───────────────────────────────────────────────────────────────────

export function initEditors(textareas) {
    textareas.forEach(initEditor)
}
?>
Онлайн: 2
Реклама