test ract

This commit is contained in:
jze9
2026-05-14 16:47:58 +05:00
parent 0ac1c8a862
commit de78624495
36 changed files with 5877 additions and 254 deletions

View File

@@ -0,0 +1,331 @@
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" />
}
/* ── Font family dropdown ── */
function FontDropdown({ editor, fonts }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
const current = editor.getAttributes('textStyle').fontFamily || 'Шрифт'
useEffect(() => {
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
return (
<div className="relative flex-shrink-0" ref={ref}>
<button
type="button"
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
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 className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-52 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] = useState(false)
const ref = useRef(null)
const current = editor.getAttributes('textStyle').fontSize?.replace('px', '') || '—'
useEffect(() => {
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
return (
<div className="relative flex-shrink-0" ref={ref}>
<button
type="button"
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
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 className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-20 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] = useState(false)
const ref = useRef(null)
useEffect(() => {
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
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="relative flex-shrink-0" ref={ref}>
<Btn
onClick={() => setOpen(o => !o)}
active={editor.isActive('table')}
title="Таблица"
>
<Table2 size={14} />
</Btn>
{open && (
<div className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-52 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="sticky top-0 z-10 flex items-center gap-0.5 px-3 py-1.5 bg-white border-b border-slate-200 shadow-sm overflow-x-auto flex-wrap min-h-[46px]">
{/* 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>
)
}