test ract
This commit is contained in:
331
web/editor-ui/src/components/Toolbar.jsx
Normal file
331
web/editor-ui/src/components/Toolbar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user