Вход Регистрация
Файл: public/assets/js/tiptap.js
Строк: 1763
<?php
import 
EditorExtensionNodemergeAttributes from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import TextAlign from '@tiptap/extension-text-align'
import TextStyleFontSize 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 TableTableRowTableHeaderTableCell 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',
    
priority1000,
    
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 [{
            
typesthis.options.types,
            
attributes: {
                
backgroundColor: {
                    default: 
null,
                    
parseHTMLel => el.style.backgroundColor || null,
                    
renderHTMLattrs => attrs.backgroundColor
                        
? { style: `background-color: ${attrs.backgroundColor}` }
                        : {},
                },
            },
        }]
    },
    
addCommands() {
        return {
            
setHighlight: ({ color }) => ({ chain }) =>
                
chain().setMark('textStyle', { backgroundColorcolor }).run(),
            
unsetHighlight: () => ({ chain }) =>
                
chain().setMark('textStyle', { backgroundColornull }).removeEmptyTextStyle().run(),
        }
    },
})
// ─── Blockquote ───────────────────────────────────────────────────────────────

const Blockquote Node.create({
    
name'blockquote',
    
group'block',
    
content'block+',
    
definingtrue,
    
addAttributes() {
        return { 
author: { default: null } }
    },
    
parseHTML() {
        return [
            {
                
tag'blockquote',
                
getAttrsel => ({ authorel.querySelector(':scope > footer')?.textContent?.trim() || null }),
                
contentElementel => 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]}`
    
url.match(/vimeo.com/(d+)/)
    if (
m) return `https://player.vimeo.com/video/${m[1]}`
    
url.match(/rutube.ru/video/([a-f0-9]+)/)
    if (
m) return `https://rutube.ru/play/embed/${m[1]}/`
    
url.match(/coub.com/view/([a-zA-Z0-9]+)/)
    if (
m) return `https://coub.com/embed/${m[1]}`
    
url.match(/(?:vk.com|vkvideo.ru)/video(-?d+_d+)/)
    if (
m) { const [oidid] = m[1].split('_'); return `https://vk.com/video_ext.php?oid=${oid}&id=${id}&hd=2` }
    
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]}`
    
embedUrl.match(/player.vimeo.com/video/(d+)/)
    if (
m) return `https://vimeo.com/${m[1]}`
    
embedUrl.match(/rutube.ru/play/embed/([a-f0-9]+)/)
    if (
m) return `https://rutube.ru/video/${m[1]}/`
    
embedUrl.match(/coub.com/embed/([a-zA-Z0-9]+)/)
    if (
m) return `https://coub.com/view/${m[1]}`
    
embedUrl.match(/vk.com/video_ext.php?oid=(-?d+)&id=(d+)/)
    if (
m) return `https://vk.com/video${m[1]}_${m[2]}`
    
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',
    
atomtrue,
    
addAttributes() { return { src: { default: null } } },
    
parseHTML() { return [{ tag'video.video[src]'getAttrsel => ({ srcel.getAttribute('src') }) }] },
    
renderHTML({ node }) {
        return [
'video', { class: 'video'srcnode.attrs.srccontrols'true'preload'metadata' }]
    },
})

const 
VideoEmbed Node.create({
    
name'videoEmbed',
    
group'block',
    
atomtrue,
    
addAttributes() { return { src: { default: null } } },
    
parseHTML() { return [{ tag'div.video'getAttrsel => ({ srcgetOriginalUrl(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', { srcembedSrcallowfullscreen'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 { 
insertVideosrc => ({ commands }) => commands.insertContent({ typethis.nameattrs: { src } }) }
    },
})

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

const AudioEmbed Node.create({
    
name'audioEmbed',
    
group'block',
    
atomtrue,
    
addAttributes() { return { src: { default: null } } },
    
parseHTML() { return [{ tag'audio[controls]'getAttrsel => ({ srcel.getAttribute('src') }) }] },
    
renderHTML({ node }) {
        return [
'audio'mergeAttributes({ controls'true' }, { srcnode.attrs.src })]
    },
    
addCommands() {
        return { 
insertAudiosrc => ({ commands }) => commands.insertContent({ typethis.nameattrs: { src } }) }
    },
})

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

const Sticker Node.create({
    
name'sticker',
    
group'inline',
    
inlinetrue,
    
atomtrue,
    
addAttributes() {
        return {
            
src: { default: null },
            
alt: { default: null },
        }
    },
    
parseHTML() {
        return [{ 
tag'img.sticker'priority100 }]
    },
    
renderHTML({ node }) {
        return [
'img', { class: 'sticker'srcnode.attrs.srcaltnode.attrs.alt }]
    },
    
addCommands() {
        return {
            
insertStickerattrs => ({ commands }) => commands.insertContent({ typethis.nameattrs }),
        }
    },
})

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

const Spoiler Node.create({
    
name'spoiler',
    
group'block',
    
content'block+',
    
definingtrue,
    
addAttributes() {
        return {
            
title: { default: 'Spoiler' },
            
open:  { default: true },
        }
    },
    
parseHTML() {
        return [{
            
tag'details.spoiler',
            
getAttrsel => ({
                
titleel.querySelector(':scope > summary')?.textContent?.trim() || 'Spoiler',
                
opentrue,
            }),
            
contentElementel => el.querySelector(':scope > div') || el,
        }]
    },
    
renderHTML({ node }) {
        
// open не сохраняем в HTML — для читателей спойлер закрыт по умолчанию
        
return ['details', { class: 'spoiler' },
            [
'summary', {}, node.attrs.title],
            [
'div', {}, 0]]
    },
    
addNodeView() {
        return ({ 
nodeinitNodegetPoseditor }) => {
            
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.preventDefault()
                
e.stopPropagation()
                const 
pos typeof getPos === 'function' getPos() : null
                
if (pos !== null) {
                    
editor.view.dispatch(
                        
editor.view.state.tr.setNodeMarkup(posnull, { ...attrsopen: !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 {
            
insertSpoilertitle => ({ commands }) => commands.insertContent({
                
typethis.nameattrs: { titleopentrue }, content: [{ type'paragraph' }],
            }),
        }
    },
})

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

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

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

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

// Когда clearNodes() конвертирует code block в параграф, переносы строк
// становятся буквальными n внутри <p>, которые браузер схлопывает в пробел.
// Нормализуем их в <br>.
function fixNewlines(html) {
    return 
html.replace(/(<pb[^>]*>)([sS]*?)(</p>)/g, (_opencontentclose) =>
        
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(btnmenu) {
    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 0menu.style.left Math.max(8parseFloat(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') }, valuenull      },
    { 
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.classList.remove('is-open'))
})

document.addEventListener('scroll', (e) => {
    
document.querySelectorAll('.tiptap-dropdown-menu.is-open').forEach(=> {
        if (
m.contains(e.target)) return  // скролл внутри самого меню — не трогаем позицию
        
if (m._repositionm._reposition()
        else if (
m._anchorBtnpositionDropdown(m._anchorBtnm)
    })
}, 
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(stickersgrid) {
        
grid.innerHTML ''
        
stickers.forEach(({ namecode }) => {
            const 
img document.createElement('img')
            
img.src name
            img
.alt code
            img
.title code
            img
.addEventListener('mousedown'=> {
                
e.preventDefault()
                
e.stopPropagation()
                
editor.chain().focus().insertSticker({ srcnamealtcode }).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((cati) => {
                const 
tab document.createElement('button')
                
tab.type 'button'
                
tab.className 'tiptap-sticker-tab' + (=== ' is-active' '')
                
tab.textContent cat.name
                tab
.addEventListener('mousedown'=> e.preventDefault())
                
tab.addEventListener('click'=> {
                    
e.stopPropagation()
                    
tabs.querySelectorAll('.tiptap-sticker-tab').forEach(=> t.classList.remove('is-active'))
                    
tab.classList.add('is-active')
                    
renderStickers(cat.stickersgrid)
                })
                
tabs.appendChild(tab)
            })

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

    function 
positionPanel() {
        const 
vw   window.innerWidth
        
const rect btn.getBoundingClientRect()
        
// Горизонталь: ограничиваем ширину экраном и прижимаем влево если вылезает
        
const panelWidth Math.min(360vw 16)
        
panel.style.width panelWidth 'px'
        
panel.style.left  Math.max(8Math.min(rect.leftvw 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.preventDefault())
    
btn.addEventListener('click'=> {
        
e.stopPropagation()
        const 
wasOpen panel.classList.contains('is-open')
        
document.querySelectorAll('.tiptap-dropdown-menu.is-open')
            .forEach(
=> m.classList.remove('is-open'))
        if (!
wasOpen) { panel._anchorBtn btnpanel._reposition positionPanelopenPanel() }
    })

    
wrap.appendChild(btn)
    return 
wrap
}

function 
makeDropdown(icontitleitemsextraClass '') {
    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(btnmenu) }

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

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

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

const suggestion = {
    
char'@',
    
minLength2,

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

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

        
function render() {
            
el.innerHTML ''
            
el.style.display items.length 'block' 'none'
            
items.forEach((itemi) => {
                const 
div document.createElement('div')
                
div.className 'mention-item' + (=== selectedIndex ' mention-item-selected' '')
                
div.textContent '@' item.login + (item.name && item.name !== item.login ' — ' item.name '')
                
div.addEventListener('mousedown'=> {
                    
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 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({ editorrangeprops }) {
        
editor.chain().focus().deleteRange(range).insertContent({
            
type'mention',
            
attrs: { idprops.loginlabelprops.login },
        }).
insertContent(' ').run()
    },
}

function 
buildToolbar(editortextareauploadImageFn) {
    const 
bar document.createElement('div')
    
bar.className 'tiptap-toolbar'

    
const activeButtons = []

    function 
btn(icontitleactiongetActive 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.preventDefault(); action() })
        if (
getActiveactiveButtons.push({ elgetActive })
        
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.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.preventDefault(); editor.chain().focus().setColor(color).run() })
        return 
el
    
}), customColorInputresetSwatch]
    const 
colorDd makeDropdown('fa-palette'__('editor.color'), colorSwatches'tiptap-colors-menu')
    
dropdown(colorDd)
    
activeButtons.push({ elcolorDd._dropdownBtngetActive: () => !!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.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({ colorcustomBgInput.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.preventDefault(); editor.chain().focus().setHighlight({ color }).run() })
        return 
el
    
}), customBgInputresetBgSwatch]
    const 
bgDd makeDropdown('fa-fill-drip'__('editor.bg_color'), bgSwatches'tiptap-colors-menu')
    
dropdown(bgDd)
    
activeButtons.push({ elbgDd._dropdownBtngetActive: () => !!editor.getAttributes('textStyle').backgroundColor })

    const 
sizeItems SIZES.map(({ labelvalue }) => {
        const 
el document.createElement('button')
        
el.type 'button'
        
el.className 'tiptap-size-item'
        
el.textContent label
        el
.addEventListener('mousedown'=> {
            
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({ elsizeDd._dropdownBtngetActive: () => !!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(({ icontitlealign }) => {
        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.preventDefault(); editor.chain().focus().setTextAlign(align).run() })
        return 
el
    
})
    const 
alignDd makeDropdown('fa-align-left'__('editor.alignment'), alignItems)
    
dropdown(alignDd)
    
activeButtons.push({ elalignDd._dropdownBtngetActive: () =>
        
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(({ icontitleaction }) => {
        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.preventDefault(); action() })
        return 
el
    
})
    const 
listDd makeDropdown('fa-list-ul'__('editor.lists'), listItems)
    
dropdown(listDd)
    
activeButtons.push({ ellistDd._dropdownBtngetActive: () =>
        
editor.isActive('bulletList') || editor.isActive('orderedList')
    })

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

    function 
tableMenuItem(icontitleaction) {
        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.preventDefault(); action() })
        return 
el
    
}

    const 
tableMenuItems = [
        
tableMenuItem('fa-table',          __('editor.table_insert'),         () => editor.chain().focus().insertTable({ rows3cols3withHeaderRowtrue }).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({ eltableDd._dropdownBtngetActive: () => editor.isActive('table') })
    
sep()

    const 
linkBtn btn('fa-link'__('editor.link'), () => {
        const 
existing editor.getAttributes('link').href || ''
        
const { fromto } = editor.state.selection
        
const selected editor.state.doc.textBetween(fromto'')
        const 
url prompt(__('editor.url_link') + ':'existing || selected)
        if (!
validateUrl(url)) return
        if (
selected || existing) {
            
editor.chain().focus().extendMarkRange('link').setLink({ hrefurltargetnull }).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({ srcurl }).run()
            return
        }

        const 
data await fetch('/ajax/resolve-image?url=' encodeURIComponent(url)).then(=> r.json())
        if (
data.image) {
            
editor.chain().focus().setImage({ srcdata.image }).run()
        } else {
            
editor.chain().focus().setImage({ srcurl }).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(editorfile)
            }
            
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 !== nulleditor.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 !== nulleditor.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 { 
fromto } = editor.state.selection
                
const text editor.state.doc.textBetween(fromto'n''n')
                
editor.chain().focus()
                    .
deleteSelection()
                    .
insertContentAt(editor.state.selection.from, { type'codeBlock'contenttext ? [{ 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(({ elgetActive }) => el.classList.toggle('is-active'getActive()))
    }
    
editor.on('selectionUpdate'updateActive)
    
editor.on('transaction'updateActive)

    return { 
barlinkBtn }
}

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

function makeEditorBubble(editorlinkBtn) {
    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({ hrefurl }).run()
    }

    
input.addEventListener('keydown'=> {
        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.stopPropagation())
    
input.addEventListener('blur'applyUrl)

    function 
makeBtn(icontitleaction) {
        const 
document.createElement('button')
        
b.type 'button'
        
b.title title
        b
.innerHTML = `<i class="fas ${icon}"></i>`
        
b.addEventListener('mousedown'=> { 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 (hrefwindow.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 bw 2
            
const overflow left bw window.innerWidth 8
            
if (overflow 0left -= overflow
            bubble
.style.left Math.max(8left) + '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(hide150)
    })
    
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(wrappertextarea)

    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(editorfilepos 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 (typeformData.append('type'type)
        if (
idformData.append('id'id)

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

        
try {
            const 
response await fetch('/ajax/file/upload', {
                
method'POST',
                
bodyformData,
                
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({
        
elementeditorEl,
        
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(=> {
                    const 
isEmpty = !p.textContent.trim() && !p.querySelector('img,audio,video,iframe')
                    if (
isEmpty && prevEmptyp.remove()
                    
prevEmpty isEmpty
                
})
                return 
doc.body.innerHTML
            
},
        },
        
extensions: [
            
StarterKit.configure({
                
blockquotefalse,
                
codeBlock: { HTMLAttributes: { class: 'code' } },
                
linkfalse,
            }),
            
CustomLink.configure({
                
openOnClickfalse,
                
autolinktrue,
                
linkOnPastetrue,
                
shouldAutoLinkurl => !/[<>"'`]/.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)
}
?>
Онлайн: 0
Реклама