Files
news_all_spo/web/editor-ui/src/components/Toolbar.jsx
2026-05-15 03:31:28 +05:00

347 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useEffect } from 'react'
import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough,
Heading1, Heading2, Heading3, Type,
AlignLeft, AlignCenter, AlignRight, AlignJustify,
List, ListOrdered, Quote, Code2,
Link2, ImageIcon, Video, Table2,
Undo2, Redo2, Palette, Highlighter, ChevronDown,
Minus,
} from 'lucide-react'
const FONT_SIZES = ['10', '11', '12', '14', '16', '18', '20', '24', '28', '32', '36', '48', '64', '72']
/* ── Primitive components ── */
function Btn({ onClick, active, disabled, title, children }) {
return (
<button
type="button"
onMouseDown={e => { e.preventDefault(); onClick?.() }}
disabled={disabled}
title={title}
className={`
relative flex items-center justify-center w-[30px] h-[30px] rounded-md text-[13px] transition-colors flex-shrink-0
${active ? 'bg-indigo-100 text-indigo-700' : 'text-slate-500 hover:bg-slate-100 hover:text-slate-800'}
${disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer'}
`}
>
{children}
</button>
)
}
function Sep() {
return <div className="w-px h-5 bg-slate-200 mx-0.5 flex-shrink-0" />
}
/* ── Hook: позиция дропдауна через fixed (вырывается из overflow-контейнеров) ── */
function useFixedDropdown() {
const [open, setOpen] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const btnRef = useRef(null)
const toggle = e => {
e.preventDefault()
if (!open && btnRef.current) {
const r = btnRef.current.getBoundingClientRect()
setPos({ top: r.bottom + 4, left: r.left })
}
setOpen(o => !o)
}
useEffect(() => {
if (!open) return
const close = e => {
if (!btnRef.current?.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [open])
return { open, setOpen, pos, btnRef, toggle }
}
/* ── Font family dropdown ── */
function FontDropdown({ editor, fonts }) {
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
const current = editor.getAttributes('textStyle').fontFamily || 'Шрифт'
return (
<div className="flex-shrink-0">
<button
ref={btnRef}
type="button"
onMouseDown={toggle}
className="flex items-center gap-1 px-2 h-[30px] rounded-md text-xs text-slate-600 hover:bg-slate-100 border border-transparent hover:border-slate-200 transition-colors max-w-[130px] cursor-pointer"
title="Шрифт"
>
<span className="truncate leading-none" style={{ fontFamily: current !== 'Шрифт' ? current : undefined }}>
{current}
</span>
<ChevronDown size={11} className="flex-shrink-0 opacity-60" />
</button>
{open && (
<div
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 208 }}
className="bg-white border border-slate-200 rounded-xl shadow-2xl max-h-72 overflow-y-auto py-1.5"
>
<button
type="button"
className="w-full px-3 py-1.5 text-left text-xs text-slate-400 hover:bg-slate-50 transition-colors"
onMouseDown={e => { e.preventDefault(); editor.chain().focus().unsetFontFamily().run(); setOpen(false) }}
>
По умолчанию
</button>
<div className="my-1 border-t border-slate-100" />
{fonts.map(f => (
<button
key={f}
type="button"
className="w-full px-3 py-1.5 text-left text-sm hover:bg-slate-50 transition-colors"
style={{ fontFamily: f }}
onMouseDown={e => { e.preventDefault(); editor.chain().focus().setFontFamily(f).run(); setOpen(false) }}
>
{f}
</button>
))}
</div>
)}
</div>
)
}
/* ── Font size dropdown ── */
function SizeDropdown({ editor }) {
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
const current = editor.getAttributes('textStyle').fontSize?.replace('px', '') || '—'
return (
<div className="flex-shrink-0">
<button
ref={btnRef}
type="button"
onMouseDown={toggle}
className="flex items-center gap-1 px-1.5 h-[30px] w-14 rounded-md text-xs text-slate-600 hover:bg-slate-100 border border-transparent hover:border-slate-200 transition-colors cursor-pointer"
title="Размер шрифта"
>
<span className="flex-1 text-center">{current}</span>
<ChevronDown size={11} className="opacity-60" />
</button>
{open && (
<div
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 80 }}
className="bg-white border border-slate-200 rounded-xl shadow-2xl max-h-56 overflow-y-auto py-1.5"
>
{FONT_SIZES.map(s => (
<button
key={s}
type="button"
className="w-full px-3 py-1 text-left text-xs hover:bg-slate-50 transition-colors"
onMouseDown={e => { e.preventDefault(); editor.chain().focus().setFontSize(`${s}px`).run(); setOpen(false) }}
>
{s}
</button>
))}
</div>
)}
</div>
)
}
/* ── Table menu ── */
function TableMenu({ editor }) {
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
const items = [
{ label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
null,
{ label: 'Добавить строку ниже', fn: () => editor.chain().focus().addRowAfter().run() },
{ label: 'Добавить строку выше', fn: () => editor.chain().focus().addRowBefore().run() },
{ label: 'Удалить строку', fn: () => editor.chain().focus().deleteRow().run(), danger: true },
null,
{ label: 'Добавить столбец справа', fn: () => editor.chain().focus().addColumnAfter().run() },
{ label: 'Добавить столбец слева', fn: () => editor.chain().focus().addColumnBefore().run() },
{ label: 'Удалить столбец', fn: () => editor.chain().focus().deleteColumn().run(), danger: true },
null,
{ label: 'Удалить таблицу', fn: () => editor.chain().focus().deleteTable().run(), danger: true },
]
return (
<div className="flex-shrink-0">
<Btn
onClick={e => toggle({ preventDefault: () => {}, ...e })}
active={editor.isActive('table')}
title="Таблица"
>
<Table2 size={14} />
</Btn>
{/* Используем ref на обёртке для Btn */}
<span ref={btnRef} style={{ display: 'none' }} />
{open && (
<div
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 208 }}
className="bg-white border border-slate-200 rounded-xl shadow-2xl py-1.5"
>
{items.map((item, i) =>
item === null ? (
<div key={i} className="my-1 border-t border-slate-100" />
) : (
<button
key={item.label}
type="button"
className={`w-full px-3 py-1.5 text-left text-xs transition-colors
${item.danger ? 'text-red-500 hover:bg-red-50' : 'text-slate-700 hover:bg-slate-50'}`}
onMouseDown={e => { e.preventDefault(); item.fn(); setOpen(false) }}
>
{item.label}
</button>
)
)}
</div>
)}
</div>
)
}
/* ── Main Toolbar ── */
export function Toolbar({ editor, onInsertImage, onInsertVideo, fonts }) {
if (!editor) return null
const setLink = () => {
const prev = editor.getAttributes('link').href || ''
const url = window.prompt('URL ссылки:', prev)
if (url === null) return
if (!url) editor.chain().focus().unsetLink().run()
else editor.chain().focus().setLink({ href: url }).run()
}
return (
<div className="flex items-center gap-0.5 px-3 py-1.5 bg-white border-b border-slate-200 shadow-sm overflow-x-auto flex-shrink-0 min-h-[46px]" style={{ scrollbarWidth: 'none' }}>
{/* History */}
<Btn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Отменить (Ctrl+Z)">
<Undo2 size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Повторить (Ctrl+Y)">
<Redo2 size={14} />
</Btn>
<Sep />
{/* Text format */}
<Btn onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Жирный (Ctrl+B)">
<Bold size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Курсив (Ctrl+I)">
<Italic size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Подчёркивание (Ctrl+U)">
<UnderlineIcon size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Зачёркивание">
<Strikethrough size={14} />
</Btn>
<Sep />
{/* Headings */}
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} title="Заголовок 1">
<Heading1 size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Заголовок 2">
<Heading2 size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Заголовок 3">
<Heading3 size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().setParagraph().run()} active={editor.isActive('paragraph') && !editor.isActive('heading')} title="Обычный текст">
<Type size={14} />
</Btn>
<Sep />
{/* Alignment */}
<Btn onClick={() => editor.chain().focus().setTextAlign('left').run()} active={editor.isActive({ textAlign: 'left' })} title="По левому краю">
<AlignLeft size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().setTextAlign('center').run()} active={editor.isActive({ textAlign: 'center' })} title="По центру">
<AlignCenter size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().setTextAlign('right').run()} active={editor.isActive({ textAlign: 'right' })} title="По правому краю">
<AlignRight size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().setTextAlign('justify').run()} active={editor.isActive({ textAlign: 'justify' })} title="По ширине">
<AlignJustify size={14} />
</Btn>
<Sep />
{/* Lists + blocks */}
<Btn onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Маркированный список">
<List size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Нумерованный список">
<ListOrdered size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Цитата">
<Quote size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Блок кода">
<Code2 size={14} />
</Btn>
<Btn onClick={() => editor.chain().focus().setHorizontalRule().run()} title="Разделитель">
<Minus size={14} />
</Btn>
<Sep />
{/* Insert media */}
<Btn onClick={setLink} active={editor.isActive('link')} title="Ссылка">
<Link2 size={14} />
</Btn>
<Btn onClick={onInsertImage} title="Вставить изображение">
<ImageIcon size={14} />
</Btn>
<Btn onClick={onInsertVideo} title="Вставить видео">
<Video size={14} />
</Btn>
<TableMenu editor={editor} />
<Sep />
{/* Typography */}
<FontDropdown editor={editor} fonts={fonts} />
<SizeDropdown editor={editor} />
{/* Text color */}
<label
className="relative flex items-center justify-center w-[30px] h-[30px] rounded-md cursor-pointer hover:bg-slate-100 transition-colors flex-shrink-0"
title="Цвет текста"
>
<Palette size={14} className="text-slate-500" />
<input
type="color"
className="absolute opacity-0 w-0 h-0"
onInput={e => editor.chain().focus().setColor(e.target.value).run()}
/>
</label>
{/* Highlight color */}
<label
className="relative flex items-center justify-center w-[30px] h-[30px] rounded-md cursor-pointer hover:bg-slate-100 transition-colors flex-shrink-0"
title="Выделение цветом"
>
<Highlighter size={14} className="text-slate-500" />
<input
type="color"
className="absolute opacity-0 w-0 h-0"
defaultValue="#fef08a"
onInput={e => editor.chain().focus().setHighlight({ color: e.target.value }).run()}
/>
</label>
</div>
)
}