347 lines
14 KiB
JavaScript
347 lines
14 KiB
JavaScript
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>
|
||
)
|
||
}
|