Визуальный редактор писем на React+Redux. Обзор, пример использования и расширения
- воскресенье, 4 июня 2017 г. в 03:14:15
Всем привет! Не так давно мне поступила задача встроить визуальный редактор email в наш сервис внутренней рассылки, ибо людям надоело набирать html руками и компоновать валидные шаблоны для писем. Побродив по интернету, я нашёл 2 редактора, которые, как мне тогда казалось, прекрасно подойдут для этих целей. Ссылки на них приведу в конце топика. Изучив их более внимательно (EmailEditor написан с использованием jQuery, который я в своё время неплохо изучил, а Mosaico был на KnockoutJS, с ним я знаком лишь поверхностно), я остановился на EmailEditor, и снова окунулся в то дерьмо из которого год назад так успешно выбрался с помощью Angular и Ionic, а именно — файлы по 2-3к строк, повсеместное и рандомное изменение DOM различными способами из различных мест и т.д., ну вы меня понимаете).
Потратив больше месяца на попытки пофиксить все баги, запилить нужные нам для рассылки строительные блоки и прочее, я сдался… Решил попробовать Mosaico и даже начал активно изучать Knockout, но проблема в том, что этот монстр (я про Mosaico) был настолько сложно написан, что EmailEditor показался не таким уж и плохим. Плюс ко всему, а точнее минус, у Mosaico практически нет вменяемой документации и если в первом я интуитивно понимал как всё работает и как создать свои блоки, то тут никакая интуиция мне не помогла. Возможно, просто не хватило мозга, терпения и желания разбираться, не знаю, просто гляньте на досуге исходники этих редакторов… А сроки поджимали...
спросил я себя, и сам же себе ответил "Конечно же, изобретать велосипед! С золотой цепью и малиновыми колёсами!". Так получилось, что как раз в этот момент для одного из своих pet-projects мне нужно было приступить к изучению популярного на сегодняшний день React+Redux подхода к построению веб приложений. Прочитав про Redux, меня осенило! Вот же оно! Состояние приложения в одном месте — это ли не лучший вариант, чтобы строить архитектуру, в которой будет меняться JSON представление шаблона письма! И я начал писать… После пары недель бессонных ночей, начальству был презентован прототип и решено попробовать внедрить мой редактор. По репозиторию может быть заметно, что в самом начале мне трудно было определиться со структурой шаблона и принципами работы, но по мере изучения, пробуя разные подходы, решил не усложнять и таки пришёл к тому, что есть сейчас, а именно:
Вот и весь 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 в которых лежат шаблоны блоков и настроек этих блоков.
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;
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:
...тут предыдущие блоки...
{
"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
Теперь создадим шаблон блока. Добавьте новый файл Block3Icons.js в папку src/components/blocks:
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:
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>⊕</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'ом) реализация, которая легко настраивается, расширяется и переписывается под свои нужды буквально за часы (реже — дни).
В планах продолжить вести работу над следующими пунктами:
Буду рад помощи, советам, критике, любому фидбеку. На основе этого решу продолжать ли заниматься проектом =).
На этом пока всё… Спасибо за внимание! В дальнейшем буду писать только об очень крупных изменениях, если, конечно, проект окажется кому-нибудь полезен.