Приветствую на этой статье!
Хочу рассказать про то, как создать собственный редактор, так как сам столкнулся с проблемой, что не везде есть функционал, который я хотел бы иметь, либо его делают платным. Так вот, легче всего мне показалось сделать собственный редактор на TipTap. Но была проблема в том, что нормальный редактор у них платный, поэтому я решил создать свой и это оказалось не так трудно.
В статье рассмотрим:
Добавление расширений TipTap
Как добавить любой язык программирования в CodeBlock
Как подсвечивать код, как в редакторе, так и в preview
Как работать с Tiptap Renderer
Добавление ссылок
Изменение изображения в редакторе
Вставка изображения через ctrl+v
Изменения стиля текста
Bubble-меню в редакторе
По итогу мы получим такой результат (стилизовать вы сможете под себя):
Вот сразу же ссылка, для тех, кто хочет протестировать:
https://stackblitz.com/edit/vitejs-vite-xghbrjp8?file=package.json
Делать будем на React, но переделать под Next.js у вас не будет проблем, так как делается всё аналогично.
Начало
Начинаем прям с полного нуля, поэтому кому неинтересна инициализация проекта, можете скипнуть до архитектуры. Первое, что нужно сделать - создать проект, можете выбирать сборщик, который вам удобен, либо повторить за мной. Я буду делать на React + Typescript (Сборщик Vite):
npm create vite@latestТакже установим все нужные нам библиотеки:
npm install @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/html @tiptap/static-renderer @tiptap/extension-bubble-menu @tiptap/extension-character-count @tiptap/extension-code-block-lowlight @tiptap/extension-color @tiptap/extension-file-handler @tiptap/extension-highlight @tiptap/extension-horizontal-rule @tiptap/extension-image @tiptap/extension-link @tiptap/extension-list @tiptap/extension-placeholder @tiptap/extension-table @tiptap/extension-table-cell @tiptap/extension-table-header @tiptap/extension-table-row @tiptap/extension-task-item @tiptap/extension-task-list @tiptap/extension-text-align @tiptap/extension-text-style @tiptap/extension-underline colord hast-util-to-html highlight.js lowlight react-colorfulЧистим проект от мусора
Почистим App.tsx и сделаем единственный div с классом editor
function App() {
return (
<div className='editor'>
</div>
)
}
export default AppУдалим файлы с картинками в папке assets, App.css
Почистим index.css и добавим стили:
@import url('https://fonts.googleapis.com/css2?family=ABeeZee:ital@0;1&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Roboto:ital,wght@0,100..900;1,100..900&display=swap');
/* Нормализуем элементы */
*, :after, :before {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Базовые стили, чтобы всё выглядело хорошо */
body {
font-family: "Roboto", sans-serif;
background-color: #000;
color: #fff;
}
/* Стили, чтобы были отступы от краёв */
.editor {
margin-top: 10px;
padding: 10px;
}Создаём структуру проекта
Добавим структуры в папке src:
папка assets (будет отвечать за изображения)
папка icons (иконки)
папка styles (стили css)
папка components (компоненты
папка utils (вспомогательные файлы)
Сразу закинем изображения с элементами для редактора (скачать)
Добавляем Реэкспорты для изображений
Реэкспорты - это объединение экспорта и импорта в одном файле, он нужен для того, чтобы в других файлах сделать красивый импорт (короткий и читаемый), т.е не с 15 файлов, а с одного. Обычно их добавляют на этапе рефакторинга, но мы сделаем сразу, чтобы потом не возвращаться к этому.
Реэкспорты изображений: создадим в папке assets файл index.ts:
export { default as SendIcon } from "./icons/send.svg";
export { default as TrashIcon } from "./icons/trash.svg";
export { default as BoldIcon } from "./icons/bold.svg";
export { default as ItalicIcon } from "./icons/italic.svg";
export { default as UnderlineIcon } from "./icons/underline.svg";
export { default as StrikeIcon } from "./icons/strike.svg";
export { default as ColorIcon } from "./icons/color.svg";
export { default as HighlightIcon } from "./icons/highlight.svg";
export { default as AlignLeftIcon } from "./icons/align-left.svg";
export { default as AlignCenterIcon } from "./icons/align-center.svg";
export { default as AlignRightIcon } from "./icons/align-right.svg";
export { default as AlignJustifyIcon } from "./icons/align-justify.svg";
export { default as LinkIcon } from "./icons/link.svg";
export { default as ImageIcon } from "./icons/image.svg";
export { default as CopyIcon } from "./icons/copy.svg";
export { default as DropDownIcon } from "./icons/dropdown.svg";Позже мы поймём как это работает.
Редактор - связующий компонент
Перейдём к связующему компоненту - самому редактору. Он будет объединять наш редактор с тулбаром, а также хранить настройки. Создадим в папке components новую папку Editor и внутрь положим Editor.tsx, я сразу показываю полный код, сейчас будем по-тихоньку добавлять все компоненты и файлы:
import { EditorContent, useEditor } from '@tiptap/react'
import { BubbleMenu } from "@tiptap/react/menus";
import { MenuBar } from '../MenuBar/MenuBar'
import type { JSONContent } from "@tiptap/core";
import { editorExtensions } from "../../utils/extensions";
import styles from './Editor.module.css'
import 'highlight.js/styles/monokai-sublime.css';
import '../../assets/styles/tiptap.css';
interface Editor {
// Вызывается при каждом изменении редактора
onChange: (content: JSONContent) => void
// Начальное содержимое редактора
value?: JSONContent
}
export default function Editor({ onChange, value }: Editor) {
const editor = useEditor({
// Все расширения редактора (таблицы, изображения,
// подсветка кода, ссылки, task list и т.д.)
extensions: editorExtensions,
// Начальное содержимое редактора
content: value,
// Для Next.js App Router отключаем немедленный рендер,
// чтобы избежать проблем с гидратацией
// immediatelyRender: false,
// HTML-атрибуты самого редактора
editorProps: {
attributes: {
class: styles.editorInner,
},
},
// Каждый раз при изменении редактора
// отдаём наружу актуальный JSON Tiptap
onUpdate: ({ editor }) => {
const jsonContent = editor.getJSON();
onChange(jsonContent);
},
})
// Пока редактор не инициализировался —
// ничего не отображаем
if (!editor) {
return null
}
return (
<div className={styles.wrapper}>
{/* Верхняя панель инструментов */}
<MenuBar editor={editor} />
{/* Плавающее меню при выделении текста */}
<BubbleMenu editor={editor}>
<MenuBar editor={editor} />
</BubbleMenu>
{/* Сам редактор */}
<EditorContent
editor={editor}
className={styles.editor}
/>
</div>
)
}Разберём всё подробнее.
У нас есть основные стили, которые мы будем использовать для редактора, для этого создадим в этой же папке Editor.module.css:
.editor {
margin-top: 15px;
border: 1px solid #2d333b;
outline: none;
border-radius: 8px;
background: #0d1117;
}
.editorInner {
outline: none !important;
min-height: 300px;
border-radius: 8px;
padding: 12px;
color: #e6edf3;
background: #0d1117;
}Этот импорт:
import 'highlight.js/styles/monokai-sublime.css';Нужен для того, чтобы была подсветка кода в редакторе и в preview.
Мы также будем импортировать собственный файл стилей, чтобы наш редактор отображал всё корректно, почти все стили туда ставились из документации TipTap:
import '../../assets/styles/tiptap.css';Вот сами стили, создаём в папке assets
/* Basic editor styles */
.tiptap {
color: #F5F7FA;
:first-child {
margin-top: 0;
}
/* List styles */
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
margin-top: 1rem;
text-wrap: pretty;
color: #FFFFFF;
font-weight: 700;
}
h1,
h2 {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
/* Inline code */
code {
background: #4F46E5;
color: #FFFFFF;
border-radius: 6px;
font-size: 0.85rem;
padding: 0.22em 0.4em;
font-weight: 500;
}
/* Code block */
pre {
background: #0D1117;
border: 1px solid #30363D;
border-radius: 10px;
color: #F5F7FA;
font-family: "JetBrains Mono", monospace;
margin: 1.5rem 0;
padding: 1rem;
overflow-x: auto;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
blockquote {
border-left: 4px solid #4F8CFF;
background: rgba(79, 140, 255, .08);
margin: 1.5rem 0;
padding: .75rem 1rem;
border-radius: 6px;
color: #CDD6E5;
}
a {
color: #6AA4FF;
text-decoration: underline;
pointer-events: none;
}
hr {
border: none;
border-top: 1px solid #2B3442;
margin: 2rem 0;
}
/* Resize */
[data-resize-handle] {
position: absolute;
background: rgba(15, 17, 23, .75);
border: 1px solid rgba(255,255,255,.85);
border-radius: 2px;
z-index: 10;
&:hover {
background: rgba(79,140,255,.85);
}
&[data-resize-handle='top-left'],
&[data-resize-handle='top-right'],
&[data-resize-handle='bottom-left'],
&[data-resize-handle='bottom-right'] {
width: 8px;
height: 8px;
}
&[data-resize-handle='top-left'] {
top: -4px;
left: -4px;
cursor: nwse-resize;
}
&[data-resize-handle='top-right'] {
top: -4px;
right: -4px;
cursor: nesw-resize;
}
&[data-resize-handle='bottom-left'] {
bottom: -4px;
left: -4px;
cursor: nesw-resize;
}
&[data-resize-handle='bottom-right'] {
bottom: -4px;
right: -4px;
cursor: nwse-resize;
}
&[data-resize-handle='top'],
&[data-resize-handle='bottom'] {
height: 6px;
left: 8px;
right: 8px;
}
&[data-resize-handle='top'] {
top: -3px;
cursor: ns-resize;
}
&[data-resize-handle='bottom'] {
bottom: -3px;
cursor: ns-resize;
}
&[data-resize-handle='left'],
&[data-resize-handle='right'] {
width: 6px;
top: 8px;
bottom: 8px;
}
&[data-resize-handle='left'] {
left: -3px;
cursor: ew-resize;
}
&[data-resize-handle='right'] {
right: -3px;
cursor: ew-resize;
}
}
[data-resize-state='true'] [data-resize-wrapper] {
outline: 1px solid rgba(79,140,255,.45);
border-radius: 2px;
}
img {
width: auto;
max-width: 100%;
border-radius: 8px;
}
li > ul,
li > ol {
margin-top: 0;
}
/* Task List */
ul[data-type='taskList'] {
list-style: none;
margin-left: 0;
padding: 0;
li {
display: flex;
align-items: flex-start;
> label {
flex: 0 0 auto;
margin-right: .5rem;
user-select: none;
}
> div {
flex: 1 1 auto;
}
}
input[type='checkbox'] {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border: 2px solid #6E7A90;
border-radius: 4px;
background: transparent;
cursor: pointer;
position: relative;
transition: .15s;
}
input[type='checkbox']:checked {
background: #4F8CFF;
border-color: #4F8CFF;
}
[data-type='taskList'] [data-checked='true'] > div > p:first-child,
[data-checked='true'] > div > p:first-child {
text-decoration: line-through;
color: #7D889C;
}
input[type='checkbox']:checked::after {
content: "";
position: absolute;
left: 5px;
top: 50%;
width: 4px;
height: 8px;
border: solid #FFFFFF;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-50%, -50%);
}
ul[data-type='taskList'] {
margin: 0;
}
}
}
/*При клике 2 раза ctrl по ссылке можно её открыть*/
.tiptap .ProseMirror-selectednode a {
pointer-events: all;
}Мы будем принимать функцию onChange, чтобы обрабатывать изменения редактора и также можем поставить начальное значение через value:
interface Editor {
// Вызывается при каждом изменении редактора
onChange: (content: JSONContent) => void
// Начальное содержимое редактора
value?: JSONContent
}Настройки editor'а
Перейдём к интересному, хук useEditor поможет нам инициализировать editor и настроить его под нас.
const editor = useEditor({
// Все расширения редактора (таблицы, изображения,
// подсветка кода, ссылки, task list и т.д.)
extensions: editorExtensions,
// Начальное содержимое редактора
content: value,
// Для Next.js App Router отключаем немедленный рендер,
// чтобы избежать проблем с гидратацией
// immediatelyRender: false,
// HTML-атрибуты самого редактора
editorProps: {
attributes: {
class: styles.editorInner,
},
},
// Каждый раз при изменении редактора
// отдаём наружу актуальный JSON Tiptap
onUpdate: ({ editor }) => {
const jsonContent = editor.getJSON();
onChange(jsonContent);
},
})extensions - расширения, которые мы будем использовать в редакторе (Расширения - это как раз весь функционал для редактора, по типу жирного шрифта, списков, codeblock'а, ссылок, и тому подобного)
content - начальное значение редактора
immediatelyRender - нужен, если вы будете использовать Next.js, чтобы избежать ошибок с гидратацией, так как иначе серверный HTML не совпадет с клиентским из-за того, что ProseMirror (движок Tiptap) завязан на браузерном окружении.
editorProps - HTML-атрибуты, я добавил собственные стили, чтобы чуть улучшить вид редактора.
onUpdate - функция, которая будет нам отдавать HTML/JSON. Вы можете сами настроить, сделав либо:
editor.getJSON();либо:
editor.getHTML();По поводу хранения на бэкенде - это ваш выбор. Я особо не заметил разницы между HTML и JSON, но HTML чаще подвержен XSS атакам. А также static renderer (библиотека Tiptap, которую мы будем использовать позже, для отображения цветного кода) принимает строго JSON, но никто вам не будет мешать использовать generateJSON().
Файл с расширениями
Ну что же создадим в папке utils файл extensions.ts:
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import Highlight from '@tiptap/extension-highlight'
import TextAlign from '@tiptap/extension-text-align'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import FileHandler from "@tiptap/extension-file-handler";
import { TextStyle } from "@tiptap/extension-text-style";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import Color from '@tiptap/extension-color';
import { lowlight } from './lowlight'
export const editorExtensions = [
// Базовый набор возможностей редактора:
// заголовки, жирный текст, курсив, списки,
// цитаты, история изменений и т.д.
StarterKit.configure({
// Используем собственную реализацию блока кода
codeBlock: false,
// Используем отдельный Link extension
link: false,
}),
// Плейсхолдер при пустом редакторе
Placeholder.configure({
placeholder: 'Начните писать статью...',
}),
// Подсветка текста маркером
Highlight.configure({
multicolor: true,
}),
// Работа со ссылками
Link.configure({
// Не открывать ссылки прямо во время редактирования
openOnClick: false,
// Автоматически превращать URL в ссылки
autolink: true,
}),
// Работа с изображениями
Image.configure({
resize: {
// Разрешаем изменение размеров изображения
enabled: true,
// Можно тянуть за любую сторону
directions: ['top', 'bottom', 'left', 'right'],
// Минимальные размеры изображения
minWidth: 50,
minHeight: 50,
// Сохранять пропорции
alwaysPreserveAspectRatio: true,
},
// Вставлять как inline-элемент
inline: true,
// Разрешить base64 изображения
allowBase64: true,
HTMLAttributes: {
loading: 'lazy',
},
}),
// Выравнивание текста
TextAlign.configure({
// Только для заголовков и параграфов
types: ['heading', 'paragraph'],
}),
// Drag&Drop и вставка изображений из буфера обмена
FileHandler.configure({
// Разрешённые форматы
allowedMimeTypes: [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
],
// Пользователь перетащил файл в редактор
onDrop: (currentEditor, files, pos) => {
files.forEach(file => {
const fileReader = new FileReader()
fileReader.readAsDataURL(file)
fileReader.onload = () => {
currentEditor
.chain()
.insertContentAt(pos, {
type: 'image',
attrs: {
src: fileReader.result,
},
})
.focus()
.run()
}
})
},
// Пользователь вставил изображение через Ctrl + V
onPaste: (currentEditor, files) => {
files.forEach(file => {
const fileReader = new FileReader()
fileReader.readAsDataURL(file)
fileReader.onload = () => {
currentEditor
.chain()
.insertContentAt(
currentEditor.state.selection.anchor,
{
type: 'image',
attrs: {
src: fileReader.result,
},
}
)
.focus()
.run()
}
})
},
}),
// Блок кода с подсветкой синтаксиса
CodeBlockLowlight.configure({
// Используем lowlight (highlight.js)
lowlight,
// Разрешить Tab внутри блока кода
enableTabIndentation: true,
// Язык по умолчанию
defaultLanguage: 'plaintext',
}),
// Позволяет изменять стиль текста
// (обязателен для Color)
TextStyle,
// Цвет текста
Color,
// Элемент списка задач (checkbox)
TaskItem.configure({
nested: true,
}),
// Сам список задач
TaskList,
]Здесь прописаны все расширения, которые будут использоваться в редакторе и конечно же, вы можете сможете позже добавить свои, зайдя в документацию Tiptap
Также, чтобы не нагромождать количеством импортов от языков программирования, создадим файл lowlight.ts в папке utils:
import { common, createLowlight } from 'lowlight'
import css from 'highlight.js/lib/languages/css'
import js from 'highlight.js/lib/languages/javascript'
import ts from 'highlight.js/lib/languages/typescript'
import html from 'highlight.js/lib/languages/xml'
import bash from 'highlight.js/lib/languages/bash'
import json from 'highlight.js/lib/languages/json'
import yaml from 'highlight.js/lib/languages/yaml'
import sql from 'highlight.js/lib/languages/sql'
import java from 'highlight.js/lib/languages/java'
import kotlin from 'highlight.js/lib/languages/kotlin'
import go from 'highlight.js/lib/languages/go'
import python from 'highlight.js/lib/languages/python'
import dockerfile from 'highlight.js/lib/languages/dockerfile'
import nginx from 'highlight.js/lib/languages/nginx'
import markdown from 'highlight.js/lib/languages/markdown'
import diff from 'highlight.js/lib/languages/diff'
import ini from 'highlight.js/lib/languages/ini'
import php from 'highlight.js/lib/languages/php'
export const lowlight = createLowlight(common)
lowlight.register('html', html)
lowlight.register('css', css)
lowlight.register('js', js)
lowlight.register('ts', ts)
lowlight.register('bash', bash)
lowlight.register('json', json)
lowlight.register('yaml', yaml)
lowlight.register('sql', sql)
lowlight.register('java', java)
lowlight.register('kotlin', kotlin)
lowlight.register('go', go)
lowlight.register('python', python)
lowlight.register('dockerfile', dockerfile)
lowlight.register('nginx', nginx)
lowlight.register('markdown', markdown)
lowlight.register('diff', diff)
lowlight.register('ini', ini)
lowlight.register('php', php)
lowlight.registerAlias({
javascript: ['js', 'jsx'],
typescript: ['ts', 'tsx'],
})Далее самый большой компонент по размеру и самый тяжёлый по реализации - тулбар, который мы уже импортировали в Edtior.tsx:
import { MenuBar } from '../MenuBar/MenuBar'Панель инструментов
Создаём в папке components папку MenuBar и туда добавляем компонент MenuBar.tsx, снова выведу полностью, чтобы разобрать по шагам:
import {Editor, useEditorState} from '@tiptap/react'
import styles from './MenuBar.module.css'
import {MenuButton} from "../MenuButton/MenuButton";
import {ColorPopover} from "../ColorPopover/ColorPopover";
import {LegendSelect} from "../LegendSelect/LegendSelect";
import { useState} from "react";
import {
AlignCenterIcon, AlignJustifyIcon,
AlignLeftIcon, AlignRightIcon,
BoldIcon,
ColorIcon,
HighlightIcon, ImageIcon,
ItalicIcon, LinkIcon,
SendIcon,
StrikeIcon,
TrashIcon,
UnderlineIcon
} from "../../assets";
interface IMenuBar {
// Экземпляр редактора TipTap
editor: Editor
}
// Возможные типы текстовых блоков
// для первого выпадающего списка.
const selectElements = [
{
id: 'paragraph',
label: 'Параграф',
},
{
id: 'h1',
label: 'Заголовок 1',
},
{
id: 'h2',
label: 'Заголовок 2',
},
{
id: 'h3',
label: 'Заголовок 3',
},
]
// Типы поддерживаемых списков.
const selectList = [
{
id: 'bulletList',
label: 'Bullet',
},
{
id: 'orderedList',
label: 'Ordered',
},
{
id: 'taskList',
label: 'Task',
},
]
// Языки, доступные для CodeBlock.
const selectLanguages = [
{ id: 'plaintext', label: 'Plain text' },
{ id: 'html', label: 'HTML' },
{ id: 'css', label: 'CSS' },
{ id: 'js', label: 'JavaScript' },
{ id: 'jsx', label: 'React JSX' },
{ id: 'ts', label: 'TypeScript' },
{ id: 'tsx', label: 'React TSX' },
{ id: 'java', label: 'Java' },
{ id: 'kotlin', label: 'Kotlin' },
{ id: 'sql', label: 'SQL' },
{ id: 'json', label: 'JSON' },
{ id: 'yaml', label: 'YAML' },
{ id: 'bash', label: 'Bash / Shell' },
{ id: 'dockerfile', label: 'Dockerfile' },
{ id: 'nginx', label: 'Nginx' },
{ id: 'diff', label: 'Diff' },
{ id: 'markdown', label: 'Markdown' },
{ id: 'php', label: 'PHP' },
]
export function MenuBar({editor}: IMenuBar) {
// Какое всплывающее окно сейчас открыто.
const [openedPanel, setOpenedPanel] =
useState<'image' | 'link' | 'color' | 'highlight' | null>(null)
// Поля формы добавления изображения.
const [imageSettings, setImageSettings] = useState(
{
imageLink: "",
alt: "",
}
)
// Поле формы изменения ссылки.
const [linkSettings, setLinkSettings] = useState(
{
href: "",
}
)
// Подписываемся только на необходимые изменения редактора,
// чтобы компонент не перерисовывался без необходимости.
const editorState = useEditorState({
editor,
selector: ctx => {
return {
// Текстовые стили
isBold: ctx.editor.isActive('bold') ?? false,
isItalic: ctx.editor.isActive('italic') ?? false,
isStrike: ctx.editor.isActive('strike') ?? false,
isHighlight: ctx.editor.isActive('highlight') ?? false,
isUnderline: ctx.editor.isActive('underline') ?? false,
// Выравнивание текста
isAlignLeft: ctx.editor.isActive({textAlign: 'left'}) ?? false,
isAlignCenter: ctx.editor.isActive({textAlign: 'center'}) ?? false,
isAlignRight: ctx.editor.isActive({textAlign: 'right'}) ?? false,
isAlignJustify: ctx.editor.isActive({textAlign: 'justify'}) ?? false,
// Тип текущего блока
isParagraph: ctx.editor.isActive('paragraph') ?? false,
isHeading1: ctx.editor.isActive('heading', {level: 1}) ?? false,
isHeading2: ctx.editor.isActive('heading', {level: 2}) ?? false,
isHeading3: ctx.editor.isActive('heading', {level: 3}) ?? false,
// Тип текущего списка
isBullet: ctx.editor.isActive('bulletList') ?? false,
isOrdered: ctx.editor.isActive('orderedList') ?? false,
isTask: ctx.editor.isActive('taskList') ?? false,
// Текущий язык CodeBlock
currentLanguage: ctx.editor.getAttributes('codeBlock').language ?? 'plaintext',
// Цвет текста
color: ctx.editor.getAttributes('textStyle').color,
// Цвет подсветки
highlight: ctx.editor.getAttributes('highlight').highlight,
}
},
})
// Добавление изображения по ссылке.
const addImage = () => {
if (imageSettings.imageLink) {
editor.chain().focus().setImage(
{
src: imageSettings.imageLink,
alt: imageSettings.alt,
title: imageSettings.alt,
}
).run()
// Очищаем форму.
setImageSettings({imageLink: "", alt: ""})
// Закрываем всплывающее окно.
setOpenedPanel(null)
}
}
// Открытие/закрытие панели управления ссылкой.
const onClickChangeLink = () => {
// Если ссылка уже существует — сразу подставляем её.
const previousUrl: string = editor.getAttributes('link').href
setLinkSettings({href: previousUrl})
if(openedPanel === 'link') {
setOpenedPanel(null)
} else {
setOpenedPanel('link')
}
}
// Переключение панели изображения.
const onClickChangeImage = () => {
if(openedPanel === 'image') {
setOpenedPanel(null)
} else {
setOpenedPanel('image')
}
}
// Переключение панели выбора цвета текста.
const onClickChangeColor = () => {
if(openedPanel === 'color') {
setOpenedPanel(null)
} else {
setOpenedPanel('color')
}
}
// Переключение панели выбора цвета подсветки.
const onClickChangeHighlight = () => {
if(openedPanel === 'highlight') {
setOpenedPanel(null)
} else {
setOpenedPanel('highlight')
}
}
// Создание или обновление ссылки.
const setLink = () => {
// Если пользователь отменил действие.
if (linkSettings.href === null) {
return
}
// Если поле пустое — удаляем ссылку.
if (linkSettings.href === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
// Устанавливаем новую ссылку.
try {
editor.chain().focus().extendMarkRange('link').setLink({ href: linkSettings.href }).run()
} catch (e) {
console.error(e)
} finally {
// Очищаем форму и закрываем панель.
setLinkSettings({href: ''})
setOpenedPanel(null)
}
}
// Удаление ссылки у текущего выделения.
const unsetLink = () => {
// Если действие было отменено.
if (linkSettings.href === null) {
return
}
// Если поле пустое — просто снимаем ссылку.
if (linkSettings.href === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
try {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
} catch (e) {
console.error(e)
} finally {
// Очищаем форму и закрываем панель.
setLinkSettings({href: ''})
setOpenedPanel(null)
}
}
// Изменение типа текущего текстового блока.
const handleBlockChange = (id: string) => {
switch (id) {
case 'h1':
editor.chain().focus().toggleHeading({level: 1}).run()
break
case 'h2':
editor.chain().focus().toggleHeading({level: 2}).run()
break
case 'h3':
editor.chain().focus().toggleHeading({level: 3}).run()
break
// по умолчанию выбран paragraph.
default:
editor.chain().focus().setParagraph().run()
}
}
// Изменение типа списка.
const handleListChange = (id: string) => {
switch (id) {
case 'orderedList':
editor.chain().focus().toggleOrderedList().run()
break
case 'taskList':
editor.chain().focus().toggleTaskList().run()
break
// По умолчанию используется маркированный список.
default:
editor.chain().focus().toggleBulletList().run()
}
}
// Преобразование выделенного текста в CodeBlock
// с выбранным языком подсветки.
const handleLanguageChange = (language: string) => {
// Получаем границы выделения.
const { from, to } = editor.state.selection
// Получаем выделенный текст.
const text = editor.state.doc.textBetween(from, to, '\n')
// Заменяем выделение блоком кода.
editor
.chain()
.focus()
.insertContentAt(
{ from, to },
{
type: 'codeBlock',
attrs: {
language,
},
content: [
{
type: 'text',
text,
},
],
},
)
.run()
}
// Используется для определения текущего Heading.
const blockTypes = [
{id: 'h1', active: editorState.isHeading1},
{id: 'h2', active: editorState.isHeading2},
{id: 'h3', active: editorState.isHeading3},
]
// Используется для определения активного списка.
const listTypes = [
{id: 'bulletList', active: editorState.isBullet},
{id: 'orderedList', active: editorState.isOrdered},
{id: 'taskList', active: editorState.isTask},
]
// Текущий выбранный тип блока.
// Если ничего не активно — Paragraph.
const currentType =
blockTypes.find(item => item.active)?.id ?? 'paragraph'
// Текущий выбранный тип списка.
// По умолчанию Bullet List.
const currentList =
listTypes.find(item => item.active)?.id ?? 'bulletList'
return (
<div className={styles.toolbar}>
<ul className={styles.toolbarList}>
{/* Жирный текст */}
<li className={styles.toolbarItem}>
<MenuButton
className={styles.toolbarBtn}
onClick={() => editor.chain().focus().toggleBold().run()}
active={editorState.isBold}
>
<img
src={BoldIcon}
alt={"bold"}
width={20}
height={20}
/>
</MenuButton>
</li>
{/* Курсив */}
<li className={styles.toolbarItem}>
<MenuButton
className={styles.toolbarBtn}
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editorState.isItalic}
>
<img
src={ItalicIcon}
alt={"italic"}
width={18}
height={18}
/>
</MenuButton>
</li>
{/* Подчёркнутый текст */}
<li className={styles.toolbarItem}>
<MenuButton
className={styles.toolbarBtn}
onClick={() => editor.chain().focus().toggleUnderline().run()}
active={editorState.isUnderline}
>
<img
src={UnderlineIcon}
alt={"underline"}
width={18}
height={18}
/>
</MenuButton>
</li>
{/* Зачёркнутый текст */}
<li className={styles.toolbarItem}>
<MenuButton
className={styles.toolbarBtn}
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editorState.isStrike}
>
<img
src={StrikeIcon}
alt={"strike"}
width={25}
height={25}
/>
</MenuButton>
</li>
<li className={styles.toolbarItem}>
<div className={styles.separator}/>
</li>
{/* Цвет текста */}
<li className={styles.toolbarItem}>
<MenuButton
className={styles.toolbarBtn}
onClick={onClickChangeColor}
>
<img
src={ColorIcon}
alt={"color"}
width={20}
height={20}
/>
</MenuButton>
{/* Палитра отображается только если открыта панель выбора цвета */}
{openedPanel === 'color' &&
<div className={`${styles.inputSetWrapper} ${styles.inputSetWrapperColor}`}>
<ColorPopover
value={editorState.color ?? '#000000'}
unset={() => editor.commands.unsetColor()}
onChange={(color) => {
editor
.chain()
.setColor(color)
.run()
}}
/>
</div>
}
</li>
{/* Цвет подсветки */}
<li className={styles.toolbarItem}>
<MenuButton
className={styles.toolbarBtn}
onClick={onClickChangeHighlight}
>
<img
src={HighlightIcon}
alt={"highlight"}
width={20}
height={20}
/>
</MenuButton>
{/* Палитра отображается только если открыта панель подсветки цвета */}
{openedPanel === 'highlight' &&
<div className={`${styles.inputSetWrapper} ${styles.inputSetWrapperColor}`}>
<ColorPopover
value={editorState.color ?? '#000000'}
unset={() => editor.commands.unsetHighlight()}
onChange={(color) => {
editor
.chain()
.setHighlight({color})
.run()
}}
/>
</div>
}
</li>
<li className={styles.toolbarItem}>
<div className={styles.separator}/>
</li>
{/* Выбор типа текстового блока */}
<li className={styles.toolbarItem}>
<LegendSelect
value={currentType}
content={selectElements}
onChange={handleBlockChange}
legend={"Элемент"}
/>
</li>
{/* Выбор типа списка */}
<li className={styles.toolbarItem}>
<LegendSelect
value={currentList}
content={selectList}
onChange={handleListChange}
legend={"Список"}
/>
</li>
<li className={styles.toolbarItem}>
<div className={styles.separator}/>
</li>
{/* Выравнивание по левому краю */}
<li className={styles.toolbarItem}>
<MenuButton
className={styles.toolbarBtn}
onClick={() => editor.chain().focus().setTextAlign('left').run()}
active={editorState.isAlignLeft}
>
<img
src={AlignLeftIcon}
alt={"align-left"}
width={25}
height={25}
/>
</MenuButton>
</li>
{/* Выравнивание по центру */}
<li className={styles.toolbarItem}>
<MenuButton
className={styles.toolbarBtn}
onClick={() => editor.chain().focus().setTextAlign('center').run()}
active={editorState.isAlignCenter}
>
<img
src={AlignCenterIcon}
alt={"align-center"}
width={25}
height={25}
/>
</MenuButton>
</li>
{/* Выравнивание по правому краю */}
<li className={styles.toolbarItem}>
<MenuButton
className={styles.toolbarBtn}
onClick={() => editor.chain().focus().setTextAlign('right').run()}
active={editorState.isAlignRight}
>
<img
src={AlignRightIcon}
alt={"align-right"}
width={25}
height={25}
/>
</MenuButton>
</li>
{/* Выравнивание по ширине */}
<li className={styles.toolbarItem}>
<MenuButton
className={styles.toolbarBtn}
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
active={editorState.isAlignJustify}
>
<img
src={AlignJustifyIcon}
alt={"align-justify"}
width={20}
height={20}
/>
</MenuButton>
</li>
<li className={styles.toolbarItem}>
<div className={styles.separator}/>
</li>
{/* Добавление / изменение ссылки */}
<li className={styles.toolbarItem}>
<MenuButton
className={styles.toolbarBtn}
onClick={onClickChangeLink}
>
<img
src={LinkIcon}
alt={"link"}
width={18}
height={18}
/>
</MenuButton>
{/* Панель настройки ссылки */}
{openedPanel === 'link' &&
<div className={styles.inputSetWrapper}>
<div>
<input value={linkSettings.href} onChange={e => setLinkSettings({...linkSettings, href: e.currentTarget.value})} placeholder={"Ссылка"}
className={styles.inputSet} type="text"/>
</div>
<button onClick={setLink} className={styles.inputSetButton}>
<img width={30} height={30} src={SendIcon} alt={"Отправить"}/>
</button>
<button onClick={unsetLink} className={styles.inputSetButton}>
<img width={20} height={20} src={TrashIcon} alt={"Отправить"}/>
</button>
</div>
}
</li>
{/* Выбор языка блока кода */}
<li className={styles.toolbarItem}>
<LegendSelect
value={editorState.currentLanguage}
content={selectLanguages}
onChange={handleLanguageChange}
legend={'CodeBlock'}
/>
</li>
<li className={styles.toolbarItem}>
<div className={styles.separator}/>
</li>
{/* Добавление изображения */}
<li className={styles.toolbarItem}>
<MenuButton
className={styles.toolbarBtn}
onClick={onClickChangeImage}
>
<img
src={ImageIcon}
alt={"image"}
width={18}
height={18}
/>
</MenuButton>
{/* Панель добавления изображения */}
{openedPanel === 'image' &&
<div className={styles.inputSetWrapper}>
<div>
{/* URL изображения */}
<input value={imageSettings.imageLink} onChange={e => setImageSettings({...imageSettings, imageLink: e.currentTarget.value})} placeholder={"Ссылка"}
className={styles.inputSet} type="text"/>
{/* alt текст изображения */}
<input value={imageSettings.alt} onChange={e => setImageSettings({...imageSettings, alt: e.currentTarget.value})} placeholder={"Описание"}
className={styles.inputSet} type="text"/>
</div>
<button onClick={addImage} className={styles.inputSetButton}>
<img width={30} height={30} src={SendIcon} alt={"Отправить"}/>
</button>
</div>
}
</li>
</ul>
</div>
)
}Во-первых мы видим, для чего нам нужны были реэкспорты, да кол-во строк возможно не поменялось, но стало однозначно лучше, так как теперь мы импортируем из одного места, а не из 15 файлов:
import {
AlignCenterIcon, AlignJustifyIcon,
AlignLeftIcon, AlignRightIcon,
BoldIcon,
ColorIcon,
HighlightIcon, ImageIcon,
ItalicIcon, LinkIcon,
SendIcon,
StrikeIcon,
TrashIcon,
UnderlineIcon
} from "../../assets";Далее то, что мы будем передавать в LegendSelect, это было сделано чтобы текст в option вы могли делать сами, а id ставили тот, который нужен Tiptap, сами id нужны как для codeblock'а например, так и для того, чтобы у нас они не хранились на русском языке и компонент LegendSelect был максимально переиспользуемый.
// Возможные типы текстовых блоков
const selectElements = [
{
id: 'paragraph',
label: 'Параграф',
},
{
id: 'h1',
label: 'Заголовок 1',
},
{
id: 'h2',
label: 'Заголовок 2',
},
{
id: 'h3',
label: 'Заголовок 3',
},
]
// Типы поддерживаемых списков.
const selectList = [
{
id: 'bulletList',
label: 'Bullet',
},
{
id: 'orderedList',
label: 'Ordered',
},
{
id: 'taskList',
label: 'Task',
},
]
// Языки, доступные для CodeBlock.
const selectLanguages = [
{ id: 'plaintext', label: 'Plain text' },
{ id: 'html', label: 'HTML' },
{ id: 'css', label: 'CSS' },
{ id: 'js', label: 'JavaScript' },
{ id: 'jsx', label: 'React JSX' },
{ id: 'ts', label: 'TypeScript' },
{ id: 'tsx', label: 'React TSX' },
{ id: 'java', label: 'Java' },
{ id: 'kotlin', label: 'Kotlin' },
{ id: 'sql', label: 'SQL' },
{ id: 'json', label: 'JSON' },
{ id: 'yaml', label: 'YAML' },
{ id: 'bash', label: 'Bash / Shell' },
{ id: 'dockerfile', label: 'Dockerfile' },
{ id: 'nginx', label: 'Nginx' },
{ id: 'diff', label: 'Diff' },
{ id: 'markdown', label: 'Markdown' },
{ id: 'php', label: 'PHP' },
]
Хотелось, чтобы можно было открыть только одно всплывающее окно, поэтому я их засунул в один state:
const [openedPanel, setOpenedPanel] =
useState<'image' | 'link' | 'color' | 'highlight' | null>(null);editorState нам нужен, чтобы видеть изменения в самом редакторе (к примеру на что навёл пользователь, какой сейчас стиль активен на элементе, который находится в фокусе). useEditorState оптимизирует компонент, чтобы он не делал лишних ререндеров:
// Подписываемся только на необходимые изменения редактора,
// чтобы компонент не перерисовывался без необходимости.
const editorState = useEditorState({
editor,
selector: ctx => {
return {
// Текстовые стили
isBold: ctx.editor.isActive('bold') ?? false,
isItalic: ctx.editor.isActive('italic') ?? false,
isStrike: ctx.editor.isActive('strike') ?? false,
isHighlight: ctx.editor.isActive('highlight') ?? false,
isUnderline: ctx.editor.isActive('underline') ?? false,
// Выравнивание текста
isAlignLeft: ctx.editor.isActive({textAlign: 'left'}) ?? false,
isAlignCenter: ctx.editor.isActive({textAlign: 'center'}) ?? false,
isAlignRight: ctx.editor.isActive({textAlign: 'right'}) ?? false,
isAlignJustify: ctx.editor.isActive({textAlign: 'justify'}) ?? false,
// Тип текущего блока
isParagraph: ctx.editor.isActive('paragraph') ?? false,
isHeading1: ctx.editor.isActive('heading', {level: 1}) ?? false,
isHeading2: ctx.editor.isActive('heading', {level: 2}) ?? false,
isHeading3: ctx.editor.isActive('heading', {level: 3}) ?? false,
// Тип текущего списка
isBullet: ctx.editor.isActive('bulletList') ?? false,
isOrdered: ctx.editor.isActive('orderedList') ?? false,
isTask: ctx.editor.isActive('taskList') ?? false,
// Текущий язык CodeBlock
currentLanguage: ctx.editor.getAttributes('codeBlock').language ?? 'plaintext',
// Цвет текста
color: ctx.editor.getAttributes('textStyle').color,
// Цвет подсветки
highlight: ctx.editor.getAttributes('highlight').highlight,
}
},
})Ещё функция addImage, которая используется для добавления изображения по клику, и функции для всплывающего окна onClickChange..., где мы по сути просто меняем значение openedPanel, кроме ссылки, так как там хотелось ещё показывать ссылку, если она существует.
Также функции setLink и unsetLink, которые нужны для вставки/сброса ссылки, логика так же была взята из документации, вы можете добавлять всё так же, а может даже улучшать с помощью ИИ или своих идей, многое на самом деле бралось из обычных примеров в документации TipTap и вам не мешает делать также. Тут, взято из обычного их примера использования:
Функции handle...Change нужны для LegendSelect, чтобы вызывалась правильная функция при нажатии на option:
const handleBlockChange = (id: string) => {
switch (id) {
case 'h1':
editor.chain().focus().toggleHeading({level: 1}).run()
break
case 'h2':
editor.chain().focus().toggleHeading({level: 2}).run()
break
case 'h3':
editor.chain().focus().toggleHeading({level: 3}).run()
break
default:
editor.chain().focus().setParagraph().run()
}
}Ну и ещё нужно было отслеживать текущий активный элемент, на который навёлся пользователь:
const blockTypes = [
{id: 'h1', active: editorState.isHeading1},
{id: 'h2', active: editorState.isHeading2},
{id: 'h3', active: editorState.isHeading3},
]
const listTypes = [
{id: 'bulletList', active: editorState.isBullet},
{id: 'orderedList', active: editorState.isOrdered},
{id: 'taskList', active: editorState.isTask},
]
const currentType =
blockTypes.find(item => item.active)?.id ?? 'paragraph'
const currentList =
listTypes.find(item => item.active)?.id ?? 'bulletList'Не забудем про стили для компонента и создадим MenuBar.module.css:
.toolbar {
display: flex;
gap: 6px;
padding: 10px;
border: 1px solid #2d333b;
border-radius: 10px;
background: #161b22;
flex-wrap: wrap;
}
.toolbarList {
list-style: none;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.toolbarItem {
position: relative;
}
.toolbarItem:not(:last-child) {
margin-right: 10px;
}
.separator {
width: 2px;
background: #30363d;
height: 25px;
}
.inputSetWrapper {
position: absolute;
top: calc(100% + 5px);
left: 0;
border: 1px solid #30363d;
border-radius: 10px;
background: #161b22;
display: flex;
align-items: center;
width: 200px;
z-index: 100;
box-shadow: 0 12px 32px rgba(0, 0, 0, .45);
}
.inputSetWrapperColor {
flex-direction: column;
width: auto;
padding: 10px;
}
.inputSetItems {
display: flex;
align-items: flex-start;
}
.inputSet {
font-weight: 400;
font-size: 14px;
line-height: 158%;
letter-spacing: 0.01em;
color: #e6edf3;
padding: 6px 10px;
border: none;
outline: none;
background: transparent;
width: 100%;
border-right: 1px solid #30363d;
}
.inputSetLegendSelect {
margin-right: 10px;
}
.inputSetColor {
margin-top: 10px;
}
.rgbaRow {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(12, 1fr);
column-gap: 10px;
}
.inputSetColorRgba {
grid-column: span 3;
}
.inputSet:not(:last-child) {
border-bottom: 1px solid #30363d;
}
.inputSet::placeholder {
color: #8b949e;
}
.inputSetButton {
cursor: pointer;
background: transparent;
border: none;
outline: none;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
transition: background .2s ease;
}
.inputSetButton:hover {
background: #21262d;
}
.inputSetButton:not(:last-child) {
border-right: 1px solid #30363d;
}
.toolbarBtn {
width: 36px;
height: 36px;
}Стили я не буду разбирать, иначе это был бы слишком базовый курс) Вы можете стилизовать всё под себя.
Так как компонент MenuBar большой, пришлось немного его декомпозировать (хотя не очень то и получилось, вы можете его ещё больше декомпозировать) и я добавил 3 компонента, которые довольно часто повторялись:
import {MenuButton} from "../MenuButton/MenuButton";
import {ColorPopover} from "../ColorPopover/ColorPopover";
import {LegendSelect} from "../LegendSelect/LegendSelect";Общая кнопка
Начнём с MenuButton, нам нужно создать в папке components новую папку MenuButton и туда добавить MenuButton.tsx, он довольно простой:
import styles from './MenuButton.module.css'
interface IMenuButton {
active?: boolean
className?: string
onClick?: () => void;
children: React.ReactNode
}
export function MenuButton({className, onClick, children, active}:IMenuButton){
return(
<button
onClick={onClick}
className={ `${styles.menuButton} ${active ? styles.active : ''} ${className ? className : ''}`}
>
{children}
</button>
)
}Я выделил 4 нужных для меня вещей:
active - если элемент активен, то ставится стиль активной кнопки
className - добавление собственных стилей
onClick - добавление интерактивности при нажатии на кнопку
children - элементы и текст которые будут внутри кнопки
Ну также добавим стили MenuButton.module.css:
.menuButton {
background: transparent;
cursor: pointer;
border: 1px solid #30363d;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: background .2s ease, border-color .2s ease;
color: #fff;
padding: 6px;
}
.menuButton:hover {
background: #21262d;
border-color: #3d444d;
}
.active {
background: #2b313a;
border-color: #4f8cff;
}Всплывающее окно со цветом
Далее идём к ColorPopover, аналогично создаём ColorPopover.tsx:
import {useState} from 'react'
import {HexColorPicker} from 'react-colorful'
import {colord} from 'colord'
import styles from './ColorPopover.module.css'
import {LegendSelect} from '../LegendSelect/LegendSelect'
import {RoundedInput} from '../RoundedInput/RoundedInput'
import {MenuButton} from "../MenuButton/MenuButton";
// Тип объекта RGBA
type RGBA = {
r: number
g: number
b: number
a: number
}
// Type Guard.
// Позволяет TypeScript понять, что переданный объект действительно имеет структуру RGBA.
const isRGBA = (color: unknown): color is RGBA => {
return color !== null && typeof color === 'object' && 'r' in color;
}
// Список форматов отображения цвета
const selectColorFormats = [
{id: 'hex', label: 'HEX'},
{id: 'rgba', label: 'RGBA'},
]
// Пропсы компонента
interface IColorPopover {
// Текущее значение цвета
value: string
// Вызывается при изменении цвета
onChange: (color: string) => void
// Необязательная функция сброса цвета
unset?: () => void
}
export function ColorPopover({
value,
onChange,
unset
}: IColorPopover) {
// Текущий режим отображения (HEX или RGBA)
const [mode, setMode] = useState<'hex' | 'rgba'>('hex')
// Храним одновременно HEX и RGBA,
// чтобы быстро переключаться между режимами без пересчётов.
const [colorSettings, setColorSettings] = useState({
hex: colord(value).toHex(),
rgba: colord(value).toRgb(),
})
// Универсальная функция обновления цвета.
// Может принимать как HEX-строку, так и объект RGBA.
const updateAll = (color: string | RGBA) => {
// Если получили строку (HEX)
if(typeof color === 'string') {
setColorSettings({
hex: color,
rgba: colord(color).toRgb()
})
// Если получили RGBA
} else if ( isRGBA(color)) {
setColorSettings({
hex: colord(color).toHex(),
rgba: color
})
}
// Если цвет валиден — меняем цвет
if(colord(color).isValid()) {
onChange(colord(color).toRgbString())
}
}
// Обновление одного из RGBA компонентов
const updateRGBA = (
key: keyof RGBA,
value: string,
) => {
// Удаляем всё кроме цифр и точки
const cleanValue = value.replace(/[^0-9.]/g, '');
// Позволяем пользователю вводить "0." или "1."
// не ломая ввод.
if (cleanValue.endsWith('.') || cleanValue.endsWith('.0')) {
updateAll({
...colorSettings.rgba,
[key]: cleanValue,
})
return;
}
let number = Number(cleanValue);
if (Number.isNaN(number)) {
number = 0;
}
// Ограничиваем диапазон
if (key === 'a') {
// Alpha всегда от 0 до 1
number = Math.max(0, Math.min(1, number));
} else {
// RGB всегда от 0 до 255
number = Math.round(Math.max(0, Math.min(255, number))); // Округляем RGB до целых
}
// Создаём новый объект цвета
const next = {
...colorSettings.rgba,
[key]: number,
}
// Обновляем оба формата
updateAll(next)
}
return (
<>
<div className={styles.inputSetItems}>
<div className={styles.inputGroup}>
{/* Выбор формата отображения цвета */}
<LegendSelect
legend="Формат"
value={mode}
content={selectColorFormats}
onChange={(v) => setMode(v as 'hex' | 'rgba')}
className={styles.inputSetLegendSelect}
/>
{/* Кнопка сброса цвета отображается,
только если передана функция unset */}
{unset &&
<MenuButton className={styles.colorUnset} onClick={unset}>
сбросить
</MenuButton>
}
</div>
{/* Палитра выбора цвета */}
<HexColorPicker
color={colorSettings.hex}
onChange={(nextHex) => {
updateAll(nextHex)
}}
/>
</div>
{/* Поле ввода HEX */}
{mode === 'hex' && (
<RoundedInput
value={colorSettings.hex}
placeholder={"#ffffff"}
className={styles.inputSetColor}
setValue={updateAll}
/>
)}
{/* Поля ввода RGBA */}
{mode === 'rgba' && (
<div className={styles.rgbaRow}>
<RoundedInput
className={styles.inputSetColorRgba}
placeholder={"255"}
value={String(colorSettings.rgba.r)}
setValue={(v) => updateRGBA('r', v)}
/>
<RoundedInput
className={styles.inputSetColorRgba}
placeholder={"255"}
value={String(colorSettings.rgba.g)}
setValue={(v) => updateRGBA('g', v)}
/>
<RoundedInput
className={styles.inputSetColorRgba}
placeholder={"255"}
value={String(colorSettings.rgba.b)}
setValue={(v) => updateRGBA('b', v)}
/>
<RoundedInput
className={styles.inputSetColorRgba}
placeholder={"1"}
value={String(colorSettings.rgba.a)}
setValue={(v) => updateRGBA('a', v)}
/>
</div>
)}
</>
)
}Этот компонент я решил сделать чуть универсальным, поэтому сделал unset в пропсах выборочным. Само изменение цвета я сделал не самым оптимизированным, так как я высчитываю сразу и rgba, и hex при изменении цвета, но надеюсь за это не будете сильно судить), была идея с попыткой высчитывать при нажатии option, но она провалилась, возможно вы сможете это сделать.
Для rgba я также добавил ограничения ввода, чтобы она корректно работала, ну и через typeGuard определял сам rgba в функции updateAll.
Стили ColorPopover.module.css:
.inputSetWrapper {
position: absolute;
top: calc(100% + 5px);
left: 0;
border: 2px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
background: #606b8f;
display: flex;
align-items: center;
width: 200px;
z-index: 100;
}
.inputSetWrapperColor {
flex-direction: column;
width: auto;
padding: 5px;
}
.inputSetItems {
display: flex;
align-items: flex-start;
}
.inputSet {
font-weight: 400;
font-size: 14px;
line-height: 158%;
letter-spacing: 0.01em;
color: var(--white);
padding: 4px 8px;
border: none;
outline: none;
background: none;
width: 100%;
border-right: 1px solid rgba(var(--foregroundGlassRGBA),0.08);
}
.inputSetLegendSelect {
margin-right: 10px;
}
.inputSetColor {
margin-top: 10px;
}
.rgbaRow {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(12, 1fr);
column-gap: 10px;
}
.inputSetColorRgba {
grid-column: 3 span;
}
.inputGroup {
margin-right: 10px;
}
.colorUnset {
margin-top: 10px;
width: 100%;
}Переиспользуемый инпут
Внутри используются RoundedInput - тоже переиспользуемый компонент, создадим RoundedInput.tsx:
import styles from './RoundedInput.module.css'
interface IRoundedInput {
className?: string;
type?: string
placeholder?: string
value: string
setValue: (v: string) => void
onBlur?: () => void;
id?: string;
name?: string
}
export const RoundedInput = ({ className, type, placeholder, value, setValue, name, id, onBlur }: IRoundedInput) => {
return (
<>
<input
onBlur={onBlur}
className={`${styles.roundedInput} ${className ? className : ''}`}
value={value}
placeholder={placeholder ? placeholder : ''}
onChange={(e) => setValue(e.currentTarget.value)}
type={type ? type : 'text'}
id={id}
name={name}
/>
</>
);
}
Здесь я добавил свой шаблон для input'а, хотите можете убрать некоторые пропсы, хотите можете добавить, в целом ничего нового.
Стили RoundedInput.module.css:
.roundedInput {
width: 100%;
letter-spacing: 0.01em;
color: #e6edf3;
outline: none;
background: #0d1117;
position: relative;
padding: 8px 12px;
border: 1px solid #30363d;
border-radius: 10px;
font-weight: 600;
font-size: 16px;
line-height: 158%;
transition: border-color .2s ease, box-shadow .2s ease;
}
.roundedInput:hover {
border-color: #3d444d;
}
.roundedInput:focus {
border-color: #4f8cff;
box-shadow: 0 0 0 3px rgba(79, 140, 255, .18);
}
.roundedInput::placeholder {
color: #8b949e;
}Инпут с легендой
Ну и тот самый LegendSelect.tsx:
import styles from './LegendSelect.module.css'
import {useState} from "react";
import {DropDownIcon} from "../../assets";
// Элемент выпадающего списка
export interface SelectItem {
id: string // внутреннее значение
label: string // отображаемый текст
}
// Пропсы компонента
interface ILegendSelect {
className?: string // дополнительные стили
value: string // выбранное значение
content: SelectItem[] // список доступных вариантов
onChange: (id: string) => void; // вызывается при выборе элемента
legend: string; // подпись над полем
}
export const LegendSelect = ({ value, content, onChange, className, legend}: ILegendSelect) => {
// Состояние открытия выпадающего списка
const [openDropdown, setOpenDropdown] = useState(false)
// Получаем текст выбранного элемента по его id.
// Если элемент не найден — выводим пустую строку.
const selected =
content.find(item => item.id === value)?.label ?? ''
return (
<fieldset className={`${styles.legendSelectFieldSet} ${className ?? ''}`}>
{/* Заголовок поля */}
<legend className={styles.legendSelectLegend}>
{legend}
</legend>
{/* Область отображения выбранного значения */}
<div
className={styles.legendSelectSelected}
onClick={() => setOpenDropdown(v => !v)}
>
{selected}
{/* Иконка раскрытия списка */}
<img
className={styles.legendSelectDropdown}
src={DropDownIcon}
alt="Ещё"
/>
</div>
{/* Выпадающий список отображается только когда открыт */}
{openDropdown && (
<ul className={styles.legendSelectOptions}>
{/* Рендерим все варианты выбора */}
{content.map(item => (
<li
key={item.id}
className={styles.legendSelectOption}
onClick={() => {
// Сообщаем родителю о новом выборе
onChange(item.id)
// Закрываем выпадающий список
setOpenDropdown(false)
}}
>
{item.label}
</li>
))}
</ul>
)}
</fieldset>
);
}Тут я решил сделать SelectItem, который бы хранил как значение id, которое передавалось бы потом для логики, так и обычный текст, который отображался бы пользователю label.
Чуть пришлось запариться со стилями в LegendSelect.module.css, но как будто оно того стоило:
.legendSelectFieldSet {
padding: 0 12px 6px 12px;
border: 2px solid #2d333b;
border-radius: 6px;
position: relative;
background: #0d1117;
}
.legendSelectLegend {
padding: 0 4px;
font-weight: 500;
font-size: 10px;
line-height: 16px;
letter-spacing: 2px;
text-transform: uppercase;
color: #8b949e;
width: max-content;
}
.legendSelectSelected {
margin: 0;
border: none;
font-weight: 500;
font-size: 16px;
line-height: 143%;
letter-spacing: 0.018em;
color: #e6edf3;
text-transform: none;
outline: none;
cursor: pointer;
display: flex;
align-items: center;
background: transparent;
}
.legendSelectDropdown {
margin-left: 5px;
}
.legendSelectOptions {
position: absolute;
top: 40px;
left: 0;
z-index: 100;
border-radius: 8px;
margin: 0;
padding: 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.legendSelectOption {
border: 1px solid #2d333b;
background: #161b22;
padding: 8px 10px;
width: 100%;
display: block;
margin: 0;
font-weight: 500;
font-size: 16px;
line-height: 143%;
letter-spacing: 0.018em;
color: #e6edf3;
transition: background-color .2s ease;
cursor: pointer;
}
.legendSelectOption:first-child {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.legendSelectOption:last-child {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.legendSelectOption:hover {
background-color: #21262d;
}Финал
С самим редактором покончили, осталось всё это вывести. Поменяем App.tsx, чтобы у нас выводилось всё в div c классом article-content:
import { useState } from "react"
import Editor from "./components/Editor/Editor"
import type {JSONContent} from "@tiptap/core";
import { renderArticle } from "./utils/renderArticle";
import "./assets/styles/article-content.css";
function App() {
const [value, setValue] = useState<JSONContent>({
"type": "doc",
content: []
})
const onUpdateData = async (val: JSONContent) => {
setValue(val)
}
return (
<div className='editor'>
<Editor value={value} onChange={onUpdateData}/>
<div className="article-content"
dangerouslySetInnerHTML={{
__html: renderArticle(value)
}}
/>
</div>
)
}
export default AppТут нам нужны новые стили article-content.css (Близкие к tiptap.css, просто убрали лишние стили по типу resize'а или отключения клика для ссылки). В папке styles создаём этот файл:
/* Basic editor styles */
.article-content {
margin-top: 10px;
border: 1px solid #2d333b;
padding: 8px;
color: #F5F7FA;
border-radius: 8px;
:first-child {
margin-top: 0;
}
/* List styles */
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
margin-top: 1rem;
text-wrap: pretty;
color: #FFFFFF;
font-weight: 700;
}
h1,
h2 {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
/* Inline code */
code {
background: #4F46E5;
color: #FFFFFF;
border-radius: 6px;
font-size: 0.85rem;
padding: 0.22em 0.4em;
font-weight: 500;
}
/* Code block */
pre {
background: #0D1117;
border: 1px solid #30363D;
border-radius: 10px;
color: #F5F7FA;
font-family: "JetBrains Mono", monospace;
margin: 1.5rem 0;
padding: 1rem;
overflow-x: auto;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
blockquote {
border-left: 4px solid #4F8CFF;
background: rgba(79, 140, 255, .08);
margin: 1.5rem 0;
padding: .75rem 1rem;
border-radius: 6px;
color: #CDD6E5;
}
a {
color: #6AA4FF;
text-decoration: underline;
}
hr {
border: none;
border-top: 1px solid #2B3442;
margin: 2rem 0;
}
img {
width: auto;
max-width: 100%;
border-radius: 8px;
}
li > ul,
li > ol {
margin-top: 0;
}
/* Task List */
ul[data-type='taskList'] {
list-style: none;
margin-left: 0;
padding: 0;
li {
display: flex;
align-items: flex-start;
> label {
flex: 0 0 auto;
margin-right: .5rem;
user-select: none;
}
> div {
flex: 1 1 auto;
}
}
input[type='checkbox'] {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border: 2px solid #6E7A90;
border-radius: 4px;
background: transparent;
cursor: pointer;
position: relative;
transition: .15s;
}
input[type='checkbox']:checked {
background: #4F8CFF;
border-color: #4F8CFF;
}
[data-type='taskList'] [data-checked='true'] > div > p:first-child,
[data-checked='true'] > div > p:first-child {
text-decoration: line-through;
color: #7D889C;
}
input[type='checkbox']:checked::after {
content: "";
position: absolute;
left: 5px;
top: 50%;
width: 4px;
height: 8px;
border: solid #FFFFFF;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-50%, -50%);
}
ul[data-type='taskList'] {
margin: 0;
}
}
}Static Renderer Tiptap
Static Renderer - это библиотека, которая преобразует структурированные данные (в нашем случае JSON) и превращает их в HTML без запуска браузера и интерактивного JS, он работает в разы быстрее, так как весит довольно мало.
Нам нужно сделать кастомный Static Renderer для того, чтобы код подсвечивался и в preview версии. Для этого в папке utils создаём файл renderArticle.ts:
import { renderToHTMLString } from '@tiptap/static-renderer/pm/html-string'
import { editorExtensions } from './extensions'
import type {JSONContent} from "@tiptap/core";
import {renderCodeBlock} from "./renderCodeBlock";
// Преобразует JSON-документ Tiptap в готовую HTML-строку
export function renderArticle(content: JSONContent) {
return renderToHTMLString({
// Исходное содержимое редактора в формате JSON
content,
// Список расширений, чтобы рендерер понимал все используемые ноды и marks
extensions: editorExtensions,
// Дополнительные настройки рендера
options: {
// Позволяет переопределить рендер отдельных типов нод.
// Здесь codeBlock будет рендериться нашей собственной функцией,
// а не стандартным способом Tiptap.
nodeMapping: {
codeBlock: renderCodeBlock,
},
},
})
}Тут раскрывается очень классная штука, мы можем сами менять ноды, которые хотим переделать под себя. И она работает с Next.js, благодаря рендеру на сервере, и позволяет не терять в SEO-оптимизации! Очень классная вещь в Tiptap.
Вот кстати то, как кастомизировать ноду под себя в renderCodeBlock.ts:
import {lowlight} from './lowlight'
import {toHtml} from 'hast-util-to-html'
import type { NodeProps } from '@tiptap/static-renderer'
import type { Node as PMNode } from '@tiptap/pm/model'
// Кастомный рендерер для ноды codeBlock.
// Вызывается static-renderer вместо стандартного рендера.
export function renderCodeBlock(
{node}: NodeProps<PMNode, string | string[]>,
): string {
// Получаем язык программирования из атрибутов блока.
// Если язык отсутствует или имеет неверный тип,
// используем plaintext.
const language =
typeof node.attrs.language === 'string'
? node.attrs.language
: 'plaintext'
// Извлекаем текст кода без HTML-разметки.
const code = node.textContent;
// Выполняем подсветку синтаксиса.
// Если язык зарегистрирован в lowlight — используем его,
// иначе подсвечиваем как обычный текст.
const tree = lowlight.registered(language)
? lowlight.highlight(language, code)
: lowlight.highlight('plaintext', code)
// Преобразуем AST (HAST) в HTML и оборачиваем
// в привычную структуру <pre><code>.
// Класс language-* пригодится для CSS или дополнительных библиотек.
return `<pre class="article-code"><code class="language-${language}">${toHtml(tree)}</code></pre>`;
}И таким образом вы можете менять всё как хотите под ваши нужды, вы можете добавлять другие классные расширения, создавать свои и именно поэтому на данный момент Tiptap является довольно крутым выбором для создания wysiwyg-редактора. Он очень гибкий и имеет хорошую документацию, не такой сложный, как Lexical и приятный для работы.
Надеюсь вам понравилась статья и вы смогли для себя получить что-то полезное от неё. В дальнейшем вы можете улучшать функционал и добавлять всё что хотите, я лишь дал начальную часть для вас и постарался немного объяснить всё, что я написал, чтобы вас подтолкнуть использовать Tiptap, так как я сам изначально не хотел морочиться и найти "Идеальный редактор", но найти такой - это либо платно, либо нереально, тем более, если вы хотите расширять свои возможности.
Если хотите больше таких статей подписывайтесь на телеграм-канал WEBCOS. Через какое-то время я сделаю вторую часть, где добавлю ИИ, чтобы легко редактировать контент и поправлять его.
Желаю успехов в ваших проектах!


