javascript

Визуальный редактор писем на React+Redux. Обзор, пример использования и расширения

  • воскресенье, 4 июня 2017 г. в 03:14:15
https://habrahabr.ru/post/329488/
  • Верстка писем
  • ReactJS
  • Node.JS
  • JavaScript


Введение


Всем привет! Не так давно мне поступила задача встроить визуальный редактор email в наш сервис внутренней рассылки, ибо людям надоело набирать html руками и компоновать валидные шаблоны для писем. Побродив по интернету, я нашёл 2 редактора, которые, как мне тогда казалось, прекрасно подойдут для этих целей. Ссылки на них приведу в конце топика. Изучив их более внимательно (EmailEditor написан с использованием jQuery, который я в своё время неплохо изучил, а Mosaico был на KnockoutJS, с ним я знаком лишь поверхностно), я остановился на EmailEditor, и снова окунулся в то дерьмо из которого год назад так успешно выбрался с помощью Angular и Ionic, а именно — файлы по 2-3к строк, повсеместное и рандомное изменение DOM различными способами из различных мест и т.д., ну вы меня понимаете).


Потратив больше месяца на попытки пофиксить все баги, запилить нужные нам для рассылки строительные блоки и прочее, я сдался… Решил попробовать Mosaico и даже начал активно изучать Knockout, но проблема в том, что этот монстр (я про Mosaico) был настолько сложно написан, что EmailEditor показался не таким уж и плохим. Плюс ко всему, а точнее минус, у Mosaico практически нет вменяемой документации и если в первом я интуитивно понимал как всё работает и как создать свои блоки, то тут никакая интуиция мне не помогла. Возможно, просто не хватило мозга, терпения и желания разбираться, не знаю, просто гляньте на досуге исходники этих редакторов… А сроки поджимали...


Что же делать?!


спросил я себя, и сам же себе ответил "Конечно же, изобретать велосипед! С золотой цепью и малиновыми колёсами!". Так получилось, что как раз в этот момент для одного из своих pet-projects мне нужно было приступить к изучению популярного на сегодняшний день React+Redux подхода к построению веб приложений. Прочитав про Redux, меня осенило! Вот же оно! Состояние приложения в одном месте — это ли не лучший вариант, чтобы строить архитектуру, в которой будет меняться JSON представление шаблона письма! И я начал писать… После пары недель бессонных ночей, начальству был презентован прототип и решено попробовать внедрить мой редактор. По репозиторию может быть заметно, что в самом начале мне трудно было определиться со структурой шаблона и принципами работы, но по мере изучения, пробуя разные подходы, решил не усложнять и таки пришёл к тому, что есть сейчас, а именно:


  • template — шаблон письма с блоками, где каждый блок содержит:
    • id — идентификатор блока (использую Date.now() и не парюсь);
    • block_type — тип блока (далее поясню для чего);
    • options — стили и свойства для контейнера и элементов блока, в котором:
      • container — объект который потом напрямую подставляется в style блока, т.е. это CSS стили;
      • elements — CSS стили для элементов блока и параметры типа source, text (я решил смешать их в кучу);
  • common — общие CSS настройки для блоков;
  • components — доступные для добавления блоками;
  • tabs — настройка видимости вкладок;
  • tinymce_config — общая настройка TinyMCE для использующих его блоков;
  • language — локализация приложения;
  • templateId — ID шаблона для сохранения\загрузки;

Вот и весь store.


Обзор работы с редактором


C чего бы начать… Здесь и далее я предполагаю что у вас установлены NodeJS, npm, и, желательно, MongoDB, а также, что у вас есть небольшой опыт работы как с ними, так и с React+Redux стеком. Запуск live development простой, поскольку проект пишется с использованием create-react-app. Так что, после того как скопируете репозиторий, просто выполните:


npm install
npm start

в папке проекта и в вашем браузере откроется адрес http://localhost:3000, где вы увидите примерно такую картину:


Смотреть


Из доступных локалей пока поддерживаются только en и ru, загрузка происходит напрямую из JSON файла в папке translations и, к сожалению, я пока не написал проверку того, доступна ли пользовательская локаль, чтобы подставить по дефолту, но это мелочи, это потом… Точка входа приложения — index.js в корне src/, там задаётся первоначальный store, и диспатчатся три action'а, чтобы загрузить локаль, список блоков и шаблон взятый по ID из вашего хранилища, либо, если ID не указан, — шаблон по умолчанию. Поскольку первоначально происходит запуск без каких-либо параметров, всё будет загружено из локальных файлов, настройка сервера на данном этапе не требуется (но понадобится для методов сохранения\загрузки шаблона, загрузки изображения и отправки тестового письма).


Интерфейс до жути простой — слева панель настроек и блоков, по центру — шаблон письма, по бокам от шаблона — кнопки. Блоки можно перетаскивать на шаблон (они добавляются как бы поверх целевого блока, смещая всё вниз), при наведении на целевой блок он меняет цвет. Тут я думаю о том, чтобы реализовать "фантомный блок", как в некоторых других редакторах, но это не приоритетная задача. При клике на блок активируется вкладка, в которой содержатся настройки для выделенного блока, и этот блок подсвечивается, а также появляется кнопка удаления блока, что видно на скриншоте:


Смотреть


Ну а если выбрать вкладку общих настроек, вы увидите набор настроек, которые будут применяться ко всем блокам, кроме тех, у которых стоит флаг Custom style. Также там есть возможность задать фон контейнера шаблона:


Смотреть


Клики на кнопках позволяют сохранить шаблон (вас попросят задать имя шаблона, но это легко выпиливается), отправить тестовое письмо и удалить блок (в планах также реализовать Undo\Redo функционал, сейчас читаю об этом)


Вы также можете запустить и поиграться с NodeJS сервером (он в папке server_nodejs), предварительно скопировав туда папку build которая появится если вы сделаете npm run build в основной папке проекта (не забудьте выполнить npm install в обеих папках!). Что умеет сервер: сохраняет\выдаёт шаблон(?id=ваш_id) и загружает изображения, а также говорит 'OK' при отправке тестового письма =). Думаю, разобраться не составит труда, структура проекта довольно простая, я вообще не люблю усложнять… Точка входа — app.js, в папке app есть Controller — там всё поведение, Router — прописаны пути и связаны с контроллером, и TemplateModel — ORM для шаблона.


Немного внутренностей


В папке src/components есть подпапки blocks и options в которых лежат шаблоны блоков и настроек этих блоков.


Пример блока с hr
import React from 'react';

const BlockHr = ({ blockOptions }) => {
    return (
        <table
            width="550"
            cellPadding="0"
            cellSpacing="0"
            role="presentation"
        >
            <tbody>
                <tr>
                    <td
                    width="550"
                    style={blockOptions.elements[0]}
                    height={blockOptions.container.height}
                    >
                    <hr />
                    </td>
                </tr>
            </tbody>
        </table>
    );
};

export default BlockHr;

Пример настройки блока с hr
import React from 'react';

const OptionsHr = ({ block, language, onPropChange }) => {
    return (
        <div>
            <div>
                <label>{language["Custom style"]}: <input type="checkbox" checked={block.options.container.customStyle? 'checked': '' } onChange={(e) => onPropChange('customStyle', !block.options.container.customStyle, true)} /></label>
            </div>
            <hr />
            <div>
                <label>{language["Height"]}: <input type="number" value={block.options.container.height} onChange={(e) => onPropChange('height', e.target.value, true)} /></label>
            </div>
            <div>
                <label>{language["Background"]}: <input type="color" value={block.options.container.backgroundColor} onChange={(e) => onPropChange('backgroundColor', e.target.value, true)} /></label>
            </div>
        </div>
    );
};

export default OptionsHr;

также в папке src/components есть файл Block.js, в котором подключены все блоки из blocks и switch...case, в котором по block_type (который я упоминал выше) определяется какой вариант блока будет возвращён.


Такой же принцип и в файле Options.js для настроек. И вот от этой архитектуры мне хотелось бы уйти как можно скорее (может у кого-то есть мысли в какую сторону осуществить переход?). В файле BlockList.js содержится шаблон письма, в котором видно, как всё устроено — в цикле строятся tr>td элементы, и td в данном случае является контейнером внутри которого уже размещается блок с элементами. Тут же подхватываются и настройки контейнера (стили из block.options.container), а также реализована DnD логика. В настройке тоже всё достаточно прозрачно, на инпуты навешаны обработчики onChange, внутри которых вызывается onPropChange(prop, value, container?, element_index) с параметрами ('свойство для изменения, например, color', новое значение свойства, элемент для изменения (контейнер — true, элемент — false), индекс элемента). В принципе это основная идея и больше рассказывать нечего =). На mindmap'е я постарался схематично изобразить работу этого конвейера:


Смотреть


P.S. В репозитории две ветки — master и react_email_editor_wordpress. В принципе особых отличий нет, различия в файлах sagas/api.js (у WP свой подход к AJAX), блоках типа feedback и social (там пути к картинкам другие… WP жеж). Редактор у нас интегрирован в WP и на данный момент тестируется.


Так как же сделать свой блок?


Очень просто! Ну мне так кажется, потому что я с этим работал плотно и каждодневно…
Начну с выбора типа блока. Бродя по интернету, я наткнулся на один симпатичный шаблон:


Смотреть


Мне понравился блок с тремя пиктограммами WEBSITES, SERVICES, SEO. Что-ж, попробую рассказать как же реализовать такой блок. Для начала давайте определимся с составом блока. Я вижу тут 6 элементов: 3 картинки и 3 текстовых элемента, ну а вы можете впоследствии запрограммировать своё видение этого блока. Поскольку я старался сделать как можно более гибкую настройку, вы вольны придумать практически любую компоновку (например 3 элемента картинка-текст), и это будет вполне реально осуществить. Довольно слов, go кодить!


Откройте файл public/components.json и добавьте следующий JSON:


Блок 3 иконки
...тут предыдущие блоки...
        {
            "preview": "images/3_icons.png",
            "block": {
                "block_type": "3_icons",
                "options": {
                    "container": {
                        "padding": "0 50px",
                        "color": "#333333",
                        "fontSize": "20px",
                        "customStyle": false,
                        "backgroundColor": "#F7F8FA"
                    },
                    "elements": [{
                        "source": "https://images.vexels.com/media/users/3/136010/isolated/preview/e7e28c15388e5196611aa2d7b7056165-ghost-skull-circle-icon-by-vexels.png"
                    },
                    {
                        "source": "http://www.1pengguna.com/1pengguna/uploads/images/tipimgdemo/kesihatan.gif"
                    },
                    {
                        "source": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/56/Circle-icons-cloud.svg/2000px-Circle-icons-cloud.svg.png"
                    },
                    {
                        "text": "DEADS",
                        "textAlign": "center"
                    },
                    {
                        "text": "LOVES",
                        "textAlign": "center"
                    },
                    {
                        "text": "CLOUDS",
                        "textAlign": "center"
                    }]
                }
            }
        },
...тут следующие блоки...

Таким образом, мы определили блок типа 3_icons с превью images/3_icons.png, контейнером и шестью элементами. У них уже есть какая-то базовая настройка стилей, чтобы при добавлении смотрелось более-менее прилично. Ок, далее открываем GIMP (если установлен) и в нём открываем файл preview_template.xcf, который лежит в корне проекта. Эту заготовку я сделал для того, чтобы клепать превью блоков. Путём нехитрых манипуляций (Cut\Paste\Colorize) из исходного изображения шаблона получим превью для будущего блока:



Сохраните его в папку src/images (или public/images, а лучше в оба места) и обновите страницу с редактором. Вы увидите, что новый блок добавился на позицию, на которой вы его вставили в components.json


У меня он, например, воткнут после HEADER


Теперь создадим шаблон блока. Добавьте новый файл Block3Icons.js в папку src/components/blocks:


Block3Icons.js
import React from 'react';

const Block3Icons = ({ blockOptions, onPropChange }) => {
    const alt="cool image";
    return (
        <table
            width="450"
            cellPadding="0"
            cellSpacing="0"
            role="presentation"
        >
            <tbody>
                <tr>
                    <td width="150">
                        <a width="150" href={blockOptions.elements[0].source}>
                            <img alt={alt} width="150" src={blockOptions.elements[0].source} />
                        </a>
                    </td>
                    <td width="150">
                        <a width="150" href={blockOptions.elements[1].source}>
                            <img alt={alt} width="150" src={blockOptions.elements[1].source} />
                        </a>
                    </td>
                    <td width="150">
                        <a width="150" href={blockOptions.elements[2].source}>
                            <img alt={alt} width="150" src={blockOptions.elements[2].source} />
                        </a>
                    </td>
                </tr>
                <tr>
                    <td style={blockOptions.elements[3]}>{blockOptions.elements[3].text}</td>
                    <td style={blockOptions.elements[4]}>{blockOptions.elements[4].text}</td>
                    <td style={blockOptions.elements[5]}>{blockOptions.elements[5].text}</td>
                </tr>
            </tbody>
        </table>
    );
};

export default Block3Icons;

Как видно, блок простейший — 2 строки 3 столбца. Из настроек для элементов я пока сделал доступными только source для элементов изображений и text для текстовых элементов, стили контейнера применяются в файле BlockList.js, о котором я упоминал выше по тексту.


Пора создать настройку блока. Добавьте новый файл Options3Icons.js в папке src/components/options:


Options3Icons.js
import React from 'react';

const Options3Icons = ({ block, language, onFileChange, onPropChange }) => {
    let textIndex = 3;
    let imageIndex = 0;
    return (
        <div>
            <div>
                <label>{language["Custom style"]}: <input type="checkbox" checked={block.options.container.customStyle? 'checked': '' } onChange={(e) => onPropChange('customStyle', !block.options.container.customStyle, true)} /></label>
            </div>
            <hr />
            <div>
                <label>{language["Color"]}: <input type="color" value={block.options.container.color} onChange={(e) => onPropChange('color', e.target.value, true)} /></label>
            </div>
            <div>
                <label>{language["Background"]}: <input type="color" value={block.options.container.backgroundColor} onChange={(e) => onPropChange('backgroundColor', e.target.value, true)} /></label>
            </div>
            <hr />
            <div>
                <label>
                    {language["URL"]}
                    <select onChange={e => imageIndex = +e.target.value}>
                        <option value="0">{language["URL"]} 1</option>
                        <option value="1">{language["URL"]} 2</option>
                        <option value="2">{language["URL"]} 3</option>
                    </select>
                </label>
            </div>
            <div>
                <label>
                    {language["URL"]} {imageIndex + 1}:
                    <label>
                        <input
                            type="file"
                            onChange={(e) => {
                                onFileChange(block, +imageIndex, e.target.files[0]);
                            }} />
                        <div>&#8853;</div>
                    </label>
                    <input type="text" value={block.options.elements[+imageIndex].source} onChange={(e) => onPropChange('source', e.target.value, false, +imageIndex)} />
                </label>
            </div>
            <hr />
            <div>
                <label>
                    {language["Text"]}
                    <select onChange={e => textIndex = +e.target.value}>
                        <option value="3">{language["Text"]} 1</option>
                        <option value="4">{language["Text"]} 2</option>
                        <option value="5">{language["Text"]} 3</option>
                    </select>
                </label>
            </div>
            <div>
                <label>
                    {language["Text"]} {textIndex - 2}
                    <input type="text" value={block.options.elements[+textIndex].text} onChange={e => onPropChange('text', e.target.value, false, +textIndex)} />
                </label>
            </div>
        </div>
    );
};

export default Options3Icons;

Отлично! Почти готово! Надеюсь, в том, что мы тут уже создали, вы хоть немного ориентируетесь? В блоке всё тупо (потому что он dumb component, т.е. рендерится только на основе своих props). В настройках каждому элементу ввода (checkbox, input, etc...) сопоставлен обработчик, в котором вызывается onPropChange для свойств (про это я тоже упоминал выше). На основе этих свойств блок динамически отрисовывается заново. Всё просто. Давайте теперь применим результаты трудов и посмотрим, наконец, работает ли это всё вообще =).


Для этого надо добавить в файл src/components/Block.js импорт нового блока и условие для его возвращения:


//...тут другие import'ы...
import Block3Icons from './blocks/Block3Icons';
//...тут тоже...

//...тут другие case'ы...
        case '3_icons':
            return <Block3Icons id={block.id} blockOptions={block.options} />;
//...и тут тоже...

Почти то же самое проделайте в файле src/containers/Options.js


//...тут другие import'ы...
import Options3Icons from '../components/options/Options3Icons';
//...тут тоже...

//...тут другие case'ы...
            case '3_icons':
                return <Options3Icons block={block} language={language} onFileChange={onFileChange} onPropChange={onPropChange} />;
//...и тут тоже...

Теперь сохраняем все файлы, и, если вы ранее запускали npm start в корне проекта, у вас должно всё скомпилироваться без ошибок. Перетащите ваш новый блок на шаблон, выделите его и поиграйтесь с его настройками. Вот пример, как это выглядит у меня:


Посмотреть


Итого


Я старался сделать редактор как можно более простым в использовании и достаточно удобным в плане интерфейса, а вышло ли у меня это или нет — решать конечно же вам. На мой взгляд получился редактор с низким порогом входа в плане внедрения и расширения компонентной базы в противовес Mosaico. Также у него гораздо более прозрачная (опять же по сравнению с Mosaico) и менее забагованная (по сравнению с EmailEditor'ом) реализация, которая легко настраивается, расширяется и переписывается под свои нужды буквально за часы (реже — дни).


В планах продолжить вести работу над следующими пунктами:


  • стили для responsive template;
  • внедрение Undo\Redo функционала;
  • сделать симпатичный дизайн (сам не могу, потребуется помощь...);
  • обособить TinyMCE чтобы не впиливать его в блоки с текст элементами;
  • использовать что-то типа styled-components для интерфейса редактора;
  • доработка NodeJS сервера. Сделаю перенос локалей и прочего из файлов в БД;
  • перенос блоков и настроек из проекта в хранилище, избавление от switch...case;
  • возможно сделаю что-то типа превью шаблона (но только после responsive);
  • возможно сделаю "Показать исходный код" для serverless выгрузки шаблона;
  • возможно сделаю упомянутый в статье "фантомный блок";
  • активная работа с issues и proposals конечно же =);
  • что-то ещё, если вспомню, допишу… да и вы пишите

Буду рад помощи, советам, критике, любому фидбеку. На основе этого решу продолжать ли заниматься проектом =).


На этом пока всё… Спасибо за внимание! В дальнейшем буду писать только об очень крупных изменениях, если, конечно, проект окажется кому-нибудь полезен.


Обещанные ссылки