Введение
Всем привет! Не так давно мне поступила задача встроить визуальный редактор 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 в которых лежат шаблоны блоков и настроек этих блоков.
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'ом) реализация, которая легко настраивается, расширяется и переписывается под свои нужды буквально за часы (реже — дни).
В планах продолжить вести работу над следующими пунктами:
- стили для responsive template;
- внедрение Undo\Redo функционала;
- сделать симпатичный дизайн (сам не могу, потребуется помощь...);
- обособить TinyMCE чтобы не впиливать его в блоки с текст элементами;
- использовать что-то типа styled-components для интерфейса редактора;
- доработка NodeJS сервера. Сделаю перенос локалей и прочего из файлов в БД;
- перенос блоков и настроек из проекта в хранилище, избавление от switch...case;
- возможно сделаю что-то типа превью шаблона (но только после responsive);
- возможно сделаю "Показать исходный код" для serverless выгрузки шаблона;
- возможно сделаю упомянутый в статье "фантомный блок";
- активная работа с issues и proposals конечно же =);
- что-то ещё, если вспомню, допишу… да и вы пишите
Буду рад помощи, советам, критике, любому фидбеку. На основе этого решу продолжать ли заниматься проектом =).
На этом пока всё… Спасибо за внимание! В дальнейшем буду писать только об очень крупных изменениях, если, конечно, проект окажется кому-нибудь полезен.
Обещанные ссылки
- (Good) Mosaico на KnockoutJS
- (Bad) EmailEditor на jQuery
- (Ugly) Мой
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Комментарии (8)
grumegargler
02.06.2017 23:50А ckeditor не рассматривали?
m0sk1t
03.06.2017 01:45У меня было не так много времени чтобы поэкспериментировать с разными WYSIWYG, да и TinyMCE у нас уже был в составе WP. Можете в двух словах рассказать в чём профит CKEditor и чем плох TinyMCE?
grumegargler
03.06.2017 05:47Я признаться, хотел вам задать такой же вопрос :-) Мы в своём почтовом клиенте используем ckeditor, было одно важное для нас требование — удобоваримая работа в старых версиях IE7+, которое ckeditor выполнял (на момент анализа вопроса) лучше всех.
zpa1972
08.06.2017 10:04Проект заинтересовал, как я понял на его основе можно сделать редактор не только для электронных писем? Мне для СИМ-собеседника и проекта игры нужно, так называемое универсальное окно диалога. В сообщениях нужно разделять эмоциональную окраску (смайлики), тематику сообщения и само сообщение конструировать на основе тематики в виде объект (модификация) — действие. Плюс генерация шаблонов: приветствие, прощание и прочее.
m0sk1t
08.06.2017 10:06В принципе, да, поведение похожее, но я так и не нашёл способа отделить шаблоны блоков и настроек от основной вёрстки для обеспечения большей гибкости и поддержки генерации…
zpa1972
08.06.2017 10:16В React'е, по моему, это одна из трудностей, встроенная в код вёрстка, нет разделения логики и оформления.
OpenMan
Подскажите, а для какой цели такие форматированные сообщения в корпоративной почте вам понадобилось рассылать? Я просто для понимания задачи. Спасибо!
m0sk1t
Для того чтобы оповещать клиентов о нововведениях, статьях, семинарах, скидках и т.д. и т.п.
В одном типичном письме содержится как минимум шапка с логотипом компании, ссылки на группы в соцсетях, а также, например, ссылка на статью о новой фиче, ссылка на семинар по внедрению и использованию этой фичи, блок с фидбеком по ней же. Возможно вы неправильно поняли, рассылка не внутри компании а среди наших клиентов. Предвижу вопрос «почему нельзя использовать существующие сервисы рассылки?» и сразу на него отвечу: у нас есть самописная КИС, и БД с почтовыми адресами клиентов находится именно там, а также есть веб-сервис, с уже написанной интеграцией с этой КИС, поэтому было решено сделать модуль рассылки поверх сервиса, а потом прикрутить к нему редактор писем.