ЛоготипЛоготип
Главная

Как создать собственный редактор на TipTap?

Как создать собственный редактор на TipTap?

Как создать собственный редактор на TipTap?

Приветствую на этой статье!

Хочу рассказать про то, как создать собственный редактор, так как сам столкнулся с проблемой, что не везде есть функционал, который я хотел бы иметь, либо его делают платным. Так вот, легче всего мне показалось сделать собственный редактор на 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. Через какое-то время я сделаю вторую часть, где добавлю ИИ, чтобы легко редактировать контент и поправлять его.

Желаю успехов в ваших проектах!

Рекомендуем