Как мы делали свой визуальный (WYSIWYG) редактор статей для Life.ru и не только

Алексей Авдеев, Neuron.Digital

Как мы делали свой визуальный (WYSIWYG) редактор статей для Life.ru и не только

Алексей Авдеев, Neuron.Digital

FrontendConf Background

👨‍💻 О себе

  1. 👨 Алексей Авдеев (https://github.com/avdeev)

Статья

            {
              title: "Название статьи",
              content: "
                  Первый абзац <strong>контента</strong>
             
                  Второй абзац контента
              ",
            }
        

Контент статьи 2009-го года

            Привет, РИТ++! Как дела?
            Привет, РИТ++! <strong>Как</strong> дела?
            Привет, РИТ++! <a href="#">Как</a> дела?
            Привет, РИТ++! Как дела?<br/><br/>
            <p align=justify>Привет, РИТ++! Как дела?</p>
            Привет, <font size=4>РИТ++!</font>Как дела?
        

Обновили TinyMCE

            <p>Привет, РИТ++! Как дела?</p>
            <div class=""></div>
            <p> </p>
            <span> </span>
            &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
        

март 2016

Статья

            {
              title: "Название статьи",
              content: "<ul>
                <li>
                  <span style="font-size: x-small;">
                    <span style="font-size:10px; line-height:16px;">Элемент</span>
                  </span>
                </li>
              </ul>",
            }
        

Угадай элемент
по запаху

Что это?

            <div class="quote">
              <div>Мы с вами дИвелоперы</div>
              <div>Вадим Макеев</div>
            </div>
        

Цитата

Что это?

<p><iframe class="medialib-video" title="" src="//embed.life.ru/video/b271fca5ff74420e78893b94cce071ca" frameborder="0" allowfullscreen="" data-extra-autoplay="0" data-extra-loop="0" data-extra-title="" data-extra-description="" data-extra-send-to-rss="0"></iframe></p>

Видео с настройками

Что это?

<div class="tiny-mce-plugin-societywidget mceNonEditable" data-mce-contenteditable="false" data-society-code="PGJsb2NrcXVvdGUgY2xhc3M9InR3aXR0ZXItdHdlZXQiIGRhdGEtbGFuZz0...sb2NrcXVvdGU+CjxzY3JpcHQgYXN5bmMgc3JjPSIvL3BsYXRmb3JtLnR3aXR0ZXIuY29tL3dpZGdldHMuanMiIGNoYXJzZXQ9InV0Zi04Ij48L3NjcmlwdD4=">

Виджет

Контент должен
стать богаче

Персона

Тест

Карточка-текст

Карточка-текст с картинкой

Карточка-картинка

Карточка-цитата

Карточка-цитата с картинкой

Карточка-видео

HTML не подходит 🚫

Что мы хотим❓

  1. 😖 Уйти от HTML (и больше его не видеть)

Заказчик

💬 О чём говорить

🎰 Бонусы для нас

📆 Нормальный план

  1. 🤔 Продумать формат контента

📆 Нормальный план

  1. 🤔 Продумать формат контента
  2. 📄 Написать документацию по формату
  3. 🛠 Реализовать редактор
  4. 🔗 Встроить редактор в админку
  5. 👴 Мигрировать базу данных
  6. 👨‍🎨 Вывести новый контент в приложения
  7. 🆙 Выпустить новый редактор в продакшен

👌 Начнём с формата

            [
              { type: "TEXT", value: "Привет, РИТ++" },
              { type: "TEXT", value: "Давно не виделись" },
              { type: "TEXT", value: "Лови фотки!" },
              { type: "GALLERY", id: "3637" },
            ]
        

Просто работать 🙆

🍫 Выбираем только поддерживаемые блоки

            content.filter(block => (
              ['HEADING', 'TEXT'].includes(block.type)
            ))
        

📹 Есть ли видео

            content.some(block => (
              block.type === 'VIDEO'
            ))
        

🖌 Склеить весь текст

            content
              .filter(block => block.type === 'TEXT')
              .reduce((accumulator, block) => (
                accumulator + block.value
              ), '')
        

Как форматировать текст?

Вариант 1. Оставить HTML

            { value: "Привет, РИТ++! <i>Как</i> дела?" },
            { value: "Привет, РИТ++! <b>Как</b> дела?" },
            { value: "Привет, РИТ++! <u>Как</u> дела?" },
            { value: "Привет, РИТ++! <s>Как</s> дела?" },
            { value: "Привет, РИТ++! <a href="#">Как</a> дела?" },
        

Вариант 2. Взять Markdown

            { value: "Это *курсив* и это тоже _курсив_" },
            { value: "Это **жирный** и это тоже __жирный__" },
            { value: "Это `важный` текст" },
            { value: "Это [пример ссылки](https://ritfest.ru//)" },
        

Тоже JSON

facebook/draft-js

👨‍🔬 Формат Draft.js

            import { Editor, EditorState } from 'draft-js';
            class MyEditor extends React.Component {
              state = { editorState: EditorState.createEmpty() };
              onChange = (editorState) => this.setState({ editorState });
              render() {
                return <Editor
                  editorState={this.state.editorState} onChange={this.onChange}
                />;
              }
            }
        

👨‍🔬 Формат Draft.js

            { blockMap: { '4iabc': {
                type: 'unstyled',
                text: 'АБ',
                characterList: [
                  { style: ['ITALIC'], entity: null },
                  { style: ['BOLD','ITALIC'], entity: null },
                ],
            } } }
        

nikgraf/awesome-draft-js

Итог

            [{ type: "TEXT", value: {
              blockMap: { '4iabc': {
                type: 'unstyled',
                text: '',
                characterList: [],
              } },
            }]
        

📆 Нормальный план

  1. 🤔 Продумать формат контента
  2. 📄 Написать документацию по формату
  3. 🛠 Реализовать редактор
  4. 🔗 Встроить редактор в админку
  5. 👴 Мигрировать базу данных
  6. 👨‍🎨 Вывести новый контент в приложения
  7. 🆙 Выпустить новый редактор в продакшен

lbovet/docson

📆 Нормальный план

  1. 🤔 Продумать формат контента
  2. 📄 Написать документацию по формату
  3. 🛠 Реализовать редактор
  4. 🔗 Встроить редактор в админку
  5. 👴 Мигрировать базу данных
  6. 👨‍🎨 Вывести новый контент в приложения
  7. 🆙 Выпустить новый редактор в продакшен

Берём

  1. 🔬 React

📆 Нормальный план

  1. 🤔 Продумать формат контента
  2. 📄 Написать документацию по формату
  3. 🛠 Реализовать редактор
  4. 🔗 Встроить редактор в админку
  5. 👴 Мигрировать базу данных
  6. 👨‍🎨 Вывести новый контент в приложения
  7. 🆙 Выпустить новый редактор в продакшен

Как можно встроить?

📆 Нормальный план

  1. 🤔 Продумать формат контента
  2. 📄 Написать документацию по формату
  3. 🛠 Реализовать редактор
  4. 🔗 Встроить редактор в админку
  5. 👴 Мигрировать базу данных
  6. 👨‍🎨 Вывести новый контент в приложения
  7. 🆙 Выпустить новый редактор в продакшен

👴 Было

            {
              title: "Название статьи",
              content: "<p>Тут какой-то старый контент</p>",
            }
        

Полиморфизм

👌 Стало

            {
              title: "Название статьи",
              content: {
                content: "<p>Тут какой-то старый контент</p>",
                content_type: "text/html",
              },
            }
        

👌 Стало

            {
              title: "Название статьи",
              content: {
                content: "[]",
                content_type: "application/json",
              },
            }
        

📆 Нормальный план

  1. 🤔 Продумать формат контента
  2. 📄 Написать документацию по формату
  3. 🛠 Реализовать редактор
  4. 🔗 Встроить редактор в админку
  5. 👴 Мигрировать базу данных
  6. 👨‍🎨 Вывести новый контент в приложения
  7. 🆙 Выпустить новый редактор в продакшен

⚙️ Схема рендера контента

Сайт life.ru (сервер, Go)

            func FormatJSON(content string, template *template.Template) string {
              contentBlocks := []Decorator{}
              var buffer bytes.Buffer
              for i, block := range contentBlocks {
                block.render(&buffer, template, i)
              }
              return buffer.String()
            }
        

Сайт life.ru (клиент, CoffeeScript)

            window.formatJSON = (content) ->
              result = ''
              try contentBlocks = JSON.parse content
              return result if not contentBlocks
              for block, i in contentBlocks
                try result += BLOCK_TEMPLATES[block.type]?(block) || ''
              result
        

Мобильные приложения (этап I)

Мобильные приложения (этап II)

RSS-ленты (Ruby)

            items.select {|el| %w(TEXT).include?(el[:type]) }.each do |item|
              element = nil
              case item[:type]
              when 'TEXT'
                element = export(item[:value])
              end
              result << element if element
            end
        

📆 Нормальный план

  1. 🤔 Продумать формат контента
  2. 📄 Написать документацию по формату
  3. 🛠 Реализовать редактор
  4. 🔗 Встроить редактор в админку
  5. 👴 Мигрировать базу данных
  6. 👨‍🎨 Вывести новый контент в приложения
  7. 🆙 Выпустить новый редактор в продакшен

Post-release
Beta

🆙 Схема миграции

  1. ➕ Добавляем новый редактор отдельной фичей

Обратно несовместимые
изменения контента ⚠

MAJOR.MINOR.PATCH

Семантическое версионирование (SemVer)

  1. МАЖОРНАЯ, когда сделаны обратно несовместимые изменения API

MODEL-REVISION-ADDITION

SchemaVer

  1. МОДЕЛЬ, когда вы вносите критическое изменение схемы, которое предотвратит взаимодействие с любыми историческими данными

Куда писать версию?

👌 Наш контент

            [
              { type: "TEXT", value: "Привет, РИТ++" },
              { type: "TEXT", value: "Давно не виделись" },
              { type: "TEXT", value: "Лови фотки!" },
              { type: "GALLERY", id: "3637" },
            ]
        

👌 Как надо было

            {
              blocks: [
                { type: "TEXT", value: "Привет, РИТ++" },
              ],
              version: '1-0-0',
              time: 1558018012033,
            }
        

👨‍🏫 Уроки

Редактируйте контент
правильно ✍

👏 Спасибо! Вопросы?

я