GrapeJS and Mosaico Integration

This commit is contained in:
witzig 2017-03-10 09:59:25 +01:00
parent c36071524d
commit 588eed008b
13 changed files with 1493 additions and 18 deletions

68
lib/editor-helpers.js Normal file
View file

@ -0,0 +1,68 @@
'use strict';
let _ = require('../lib/translate')._;
let helpers = require('../lib/helpers');
let templates = require('../lib/models/templates');
let campaigns = require('../lib/models/campaigns');
module.exports = {
getResource
};
function getResource(type, id, callback) {
if (type === 'template') {
templates.get(id, (err, template) => {
if (err || !template) {
return callback(err && err.message || err || _('Could not find template with specified ID'));
}
getMergeTagsForResource(template, (err, mergeTags) => {
if (err) {
return callback(err.message || err);
}
template.mergeTags = mergeTags;
return callback(null, template);
});
});
} else if (type === 'campaign') {
campaigns.get(id, false, (err, campaign) => {
if (err || !campaign) {
return callback(err && err.message || err || _('Could not find campaign with specified ID'));
}
getMergeTagsForResource(campaign, (err, mergeTags) => {
if (err) {
return callback(err.message || err);
}
campaign.mergeTags = mergeTags;
return callback(null, campaign);
});
});
} else {
return callback(_('Invalid resource type'));
}
}
function getMergeTagsForResource(resource, callback) {
helpers.getDefaultMergeTags((err, defaultMergeTags) => {
if (err) {
return callback(err.message || err);
}
if (!resource.list) {
return callback(null, defaultMergeTags);
}
helpers.getListMergeTags(resource.list, (err, listMergeTags) => {
if (err) {
return callback(err.message || err);
}
callback(null, defaultMergeTags.concat(listMergeTags));
});
});
}

433
routes/editorapi.js Normal file
View file

@ -0,0 +1,433 @@
'use strict';
let config = require('config');
let express = require('express');
let router = new express.Router();
let passport = require('../lib/passport');
let os = require('os');
let fs = require('fs');
let path = require('path');
let mkdirp = require('mkdirp');
let cache = require('memory-cache');
let crypto = require('crypto');
let fetch = require('node-fetch');
let events = require('events');
let httpMocks = require('node-mocks-http');
let multiparty = require('multiparty');
let fileType = require('file-type');
let escapeStringRegexp = require('escape-string-regexp');
let jqueryFileUpload = require('jquery-file-upload-middleware');
let gm = require('gm').subClass({ imageMagick: true });
let url = require('url');
let htmlToText = require('html-to-text');
let premailerApi = require('premailer-api');
let editorHelpers = require('../lib/editor-helpers');
let _ = require('../lib/translate')._;
let mailer = require('../lib/mailer');
let settings = require('../lib/models/settings');
let templates = require('../lib/models/templates');
let campaigns = require('../lib/models/campaigns');
router.all('/*', (req, res, next) => {
if (!req.user && !cache.get(req.get('If-Match'))) {
return res.status(403).send(_('Need to be logged in to access restricted content'));
}
if (req.originalUrl.startsWith('/editorapi/img?')) {
return next();
}
if (!config.editors.map(e => e[0]).includes(req.query.editor)) {
return res.status(500).send(_('Invalid editor name'));
}
next();
});
jqueryFileUpload.on('begin', fileInfo => {
fileInfo.name = fileInfo.name
.toLowerCase()
.replace(/ /g, '-')
.replace(/[^a-z0-9+-\.]+/g, '');
});
let listImages = (dir, dirURL, callback) => {
fs.readdir(dir, (err, files = []) => {
if (err && err.code !== 'ENOENT') {
return callback(err.message || err);
}
files = files.filter(name => /\.(jpe?g|png|gif)$/i.test(name));
files = files.map(name => {
return {
// mosaico
name,
url: dirURL + '/' + name,
thumbnailUrl: dirURL + '/thumbnail/' + name,
// grapejs
src: dirURL + '/' + name,
};
});
callback(null, files);
});
};
let getStaticImageUrl = ({ dynamicUrl, staticDir, staticDirUrl }, callback) => {
mkdirp(staticDir, err => {
if (err) {
return callback(dynamicUrl);
}
fs.readdir(staticDir, (err, files) => {
if (err) {
return callback(dynamicUrl);
}
let hash = crypto.createHash('md5').update(dynamicUrl).digest('hex');
let match = files.find(el => el.startsWith(hash));
let headers = {};
if (match) {
return callback(staticDirUrl + '/' + match);
}
if (dynamicUrl.includes('/editorapi/img?')) {
let token = crypto.randomBytes(16).toString('hex');
cache.put(token, true, 1000);
headers['If-Match'] = token;
}
fetch(dynamicUrl, {
headers
})
.then(res => {
return res.buffer();
})
.then(buffer => {
let ft = fileType(buffer);
if (!ft) {
return callback(dynamicUrl);
}
if (['image/jpeg', 'image/png', 'image/gif'].includes(ft.mime)) {
fs.writeFile(path.join(staticDir, hash + '.' + ft.ext), buffer, err => {
if (err) {
return callback(dynamicUrl);
}
let staticUrl = staticDirUrl + '/' + hash + '.' + ft.ext;
callback(staticUrl);
});
} else {
callback(dynamicUrl);
}
});
});
});
};
let prepareHtml = ({ editorName, html }, callback) => {
settings.get('serviceUrl', (err, serviceUrl) => {
if (err) {
return callback(err.message || err);
}
let jobs = 0;
let srcs = {};
let re = /<img[^>]+src="([^"]+)"/g;
let result;
while ((result = re.exec(html)) !== null) {
srcs[result[1]] = result[1];
}
let done = () => {
if (jobs === 0) {
Object.keys(srcs).forEach(src => {
// console.log(`replace dynamic - ${src} - with static - ${srcs[src]}`);
html = html.replace(new RegExp(escapeStringRegexp(src), 'g'), srcs[src]);
});
callback(null, html);
}
};
Object.keys(srcs).forEach(src => {
jobs++;
let dynamicUrl = src.replace(/&amp;/g, '&');
dynamicUrl = /^https?:\/\/|^\/\//i.test(dynamicUrl) ? dynamicUrl : url.resolve(serviceUrl, dynamicUrl);
getStaticImageUrl({
dynamicUrl,
staticDir: path.join(__dirname, '..', 'public', editorName, 'uploads', 'static'),
staticDirUrl: url.resolve(serviceUrl, editorName + '/uploads/static'),
}, staticUrl => {
srcs[src] = staticUrl;
jobs--;
done();
});
});
done();
});
};
let placeholderImage = (req, res, { width, height }) => {
let magick = gm(width, height, '#707070');
let x = 0;
let y = 0;
let size = 40;
// stripes
while (y < height) {
magick = magick
.fill('#808080')
.drawPolygon([x, y], [x + size, y], [x + size * 2, y + size], [x + size * 2, y + size * 2])
.drawPolygon([x, y + size], [x + size, y + size * 2], [x, y + size * 2]);
x = x + size * 2;
if (x > width) {
x = 0;
y = y + size * 2;
}
}
// text
magick = magick
.fill('#B0B0B0')
.fontSize(20)
.drawText(0, 0, width + ' x ' + height, 'center');
res.set('Content-Type', 'image/png');
magick.stream('png').pipe(res);
};
let resizedImage = (req, res, { src, method, width, height }) => {
let magick = gm(src);
magick.format((err, format) => {
if (err) {
return res.status(500).send(err.message || err);
}
switch (method) {
case 'resize':
res.set('Content-Type', 'image/' + format.toLowerCase());
magick.autoOrient()
.resize(width, height)
.stream()
.pipe(res);
return;
case 'cover':
res.set('Content-Type', 'image/' + format.toLowerCase());
magick.autoOrient()
.resize(width, height + '^')
.gravity('Center')
.extent(width, height + '>')
.stream()
.pipe(res);
return;
default:
res.status(501).send(_('Method not supported'));
}
});
};
// /editorapi/img?src=" + encodeURIComponent(src) + "&method=" + encodeURIComponent(method) + "&params=" + encodeURIComponent(width + "," + height);
router.get('/img', passport.csrfProtection, (req, res) => {
settings.get('serviceUrl', (err, serviceUrl) => {
if (err) {
return res.status(500).send(err.message || err);
}
let { src, method, params = '600,null' } = req.query;
let width = params.split(',')[0];
let height = params.split(',')[1];
width = (width === 'null') ? null : Number(width);
height = (height === 'null') ? null : Number(height);
switch (method) {
case 'placeholder':
return placeholderImage(req, res, { width, height });
case 'resize':
case 'cover':
src = /^https?:\/\/|^\/\//i.test(src) ? src : url.resolve(serviceUrl, src);
return resizedImage(req, res, { src, method, width, height });
default:
return res.status(501).send(_('Method not supported'));
}
});
});
router.post('/update', passport.parseForm, passport.csrfProtection, (req, res) => {
prepareHtml({ editorName: req.query.editor, html: req.body.html }, (err, html) => {
if (err) {
return res.status(500).send(err.message || err);
}
req.body.html = html;
if (req.query.type === 'template') {
templates.update(req.body.id, req.body, (err, updated) => {
if (err) {
return res.status(500).send(err.message || err);
}
res.send('ok');
});
} else if (req.query.type === 'campaign') {
campaigns.update(req.body.id, req.body, (err, updated) => {
if (err) {
return res.status(500).send(err.message || err);
}
res.send('ok');
});
} else {
res.status(500).send(_('Invalid resource type'));
}
});
});
// https://github.com/artf/grapesjs/wiki/API-Asset-Manager
// https://github.com/aguidrevitch/jquery-file-upload-middleware
router.get('/upload', passport.csrfProtection, (req, res) => {
settings.get('serviceUrl', (err, serviceUrl) => {
if (err) {
return res.status(500).send(err.message || err);
}
let baseDir = path.join(__dirname, '..', 'public', req.query.editor, 'uploads');
let baseDirUrl = serviceUrl + req.query.editor + '/uploads';
listImages(path.join(baseDir, '0'), baseDirUrl + '/0', (err, sharedImages) => {
if (err) {
return res.status(500).send(err.message || err);
}
if (req.query.type === 'campaign' && Number(req.query.id) > 0) {
listImages(path.join(baseDir, req.query.id), baseDirUrl + '/' + req.query.id, (err, campaignImages) => {
err ? res.status(500).send(err.message || err)
: res.json({ files: sharedImages.concat(campaignImages) });
});
} else {
res.json({ files: sharedImages });
}
});
});
});
router.post('/upload', passport.csrfProtection, (req, res) => {
let dirName = req.query.type === 'template' ? '0'
: req.query.type === 'campaign' && Number(req.query.id) > 0 ? req.query.id
: null;
if (dirName === null) {
return res.status(500).send(_('Invalid resource type or ID'));
}
let opts = {
tmpDir: config.www.tmpdir || os.tmpdir(),
imageVersions: req.query.editor === 'mosaico' ? { thumbnail: { width: 90, height: 90 } } : {},
uploadDir: path.join(__dirname, '..', 'public', req.query.editor, 'uploads', dirName),
uploadUrl: '/' + req.query.editor + '/uploads/' + dirName, // must be root relative
acceptFileTypes:/(\.|\/)(gif|jpe?g|png)$/i,
};
let mockres = httpMocks.createResponse({
eventEmitter: events.EventEmitter
});
mockres.on('end', () => {
if (req.query.editor === 'grapejs') {
let data = [];
JSON.parse(mockres._getData()).files.forEach(file => {
data.push({ src: file.url });
});
res.json({ data });
} else {
res.send(mockres._getData());
}
});
jqueryFileUpload.fileHandler(opts)(req, mockres);
});
router.post('/download', passport.csrfProtection, (req, res) => {
prepareHtml({ editorName: req.query.editor, html: req.body.html }, (err, html) => {
if (err) {
return res.status(500).send(err.message || err);
}
res.setHeader('Content-disposition', 'attachment; filename=' + req.body.filename);
res.setHeader('Content-type', 'text/html');
res.send(html);
});
});
let parseGrapejsMultipartTestForm = (req, res, next) => {
if (req.query.editor === 'grapejs') {
new multiparty.Form().parse(req, (err, fields, files) => {
req.body.email = fields.email[0];
req.body.subject = fields.subject[0];
req.body.html = fields.html[0];
req.body._csrf = fields._csrf[0];
next();
});
} else {
next();
}
};
router.post('/test', parseGrapejsMultipartTestForm, passport.csrfProtection, (req, res) => {
prepareHtml({ editorName: req.query.editor, html: req.body.html }, (err, html) => {
if (err) {
req.query.editor === 'grapejs'
? res.status(500).json({ errors: err.message || err })
: res.status(500).send(err.message || err);
return;
}
settings.list(['defaultAddress', 'defaultFrom'], (err, configItems) => {
if (err) {
req.query.editor === 'grapejs'
? res.status(500).json({ errors: err.message || err })
: res.status(500).send(err.message || err);
return;
}
mailer.getMailer((err, transport) => {
if (err) {
req.query.editor === 'grapejs'
? res.status(500).json({ errors: err.message || err })
: res.status(500).send(err.message || err);
return;
}
let opts = {
from: {
name: configItems.defaultFrom,
address: configItems.defaultAddress,
},
to: req.body.email,
subject: req.body.subject,
text: htmlToText.fromString(html, { wordwrap: 100 }),
html,
};
transport.sendMail(opts, (err, info) => {
if (err) {
req.query.editor === 'grapejs'
? res.status(500).json({ errors: err.message || err })
: res.status(500).send(err.message || err);
return;
}
req.query.editor === 'grapejs'
? res.json({ data: 'ok' })
: res.send('ok');
});
});
});
});
});
router.post('/html-to-text', passport.parseForm, passport.csrfProtection, (req, res) => {
premailerApi.prepare({ html: req.body.html, fetchHTML: false }, (err, email) => {
if (err) {
return res.status(500).send(err.message || err);
}
res.send(email.text.replace(/%5B/g, '[').replace(/%5D/g, ']'));
});
});
module.exports = router;

50
routes/grapejs.js Normal file
View file

@ -0,0 +1,50 @@
'use strict';
let config = require('config');
let express = require('express');
let router = new express.Router();
let passport = require('../lib/passport');
let fs = require('fs');
let path = require('path');
let editorHelpers = require('../lib/editor-helpers.js')
router.all('/*', (req, res, next) => {
if (!req.user) {
req.flash('danger', _('Need to be logged in to access restricted content'));
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
}
next();
});
router.get('/editor', passport.csrfProtection, (req, res) => {
editorHelpers.getResource(req.query.type, req.query.id, (err, resource) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/');
}
resource.editorName = resource.editorName || 'grapejs';
resource.editorData = !resource.editorData
? { template: req.query.template || 'demo' }
: JSON.parse(resource.editorData);
if (!resource.html && !resource.editorData.html) {
try {
let file = path.join(__dirname, '..', 'public', 'grapejs', 'templates', resource.editorData.template, 'index.html');
resource.html = fs.readFileSync(file, 'utf8');
} catch (err) {
resource.html = err.message || err;
}
}
res.render('grapejs/editor', {
layout: 'grapejs/layout-editor',
type: req.query.type,
resource,
editorConfig: config.grapejs,
csrfToken: req.csrfToken(),
});
});
});
module.exports = router;

56
routes/mosaico.js Normal file
View file

@ -0,0 +1,56 @@
'use strict';
let config = require('config');
let express = require('express');
let router = new express.Router();
let passport = require('../lib/passport');
let fs = require('fs');
let path = require('path');
let _ = require('../lib/translate')._;
let editorHelpers = require('../lib/editor-helpers');
router.all('/*', (req, res, next) => {
if (!req.user) {
req.flash('danger', _('Need to be logged in to access restricted content'));
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
}
next();
});
router.get('/editor', passport.csrfProtection, (req, res) => {
editorHelpers.getResource(req.query.type, req.query.id, (err, resource) => {
if (err) {
req.flash('danger', err.message || err);
return res.redirect('/');
}
let getLanguageStrings = language => {
if (!language || language === 'en') {
return null;
}
language = language.split('_')[0];
try {
let file = path.join(__dirname, '..', 'public', 'mosaico', 'dist', 'lang', 'mosaico-' + language + '.json');
return fs.readFileSync(file, 'utf8');
} catch (err) {
return null;
}
}
resource.editorName = resource.editorName || 'mosaico';
resource.editorData = !resource.editorData
? { template: req.query.template || 'versafix-1' }
: JSON.parse(resource.editorData);
res.render('mosaico/editor', {
layout: 'mosaico/layout-editor',
type: req.query.type,
resource,
editorConfig: config.mosaico,
languageStrings: getLanguageStrings(config.language),
csrfToken: req.csrfToken(),
});
});
});
module.exports = router;

371
views/grapejs/editor.hbs Normal file
View file

@ -0,0 +1,371 @@
<style>
html,
body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
iframe {
user-select: none;
-webkit-user-select: none;
}
#gjs-wrapper {
position: absolute;
top: 34px;
bottom: 0;
width: 100%;
}
#toast-container {
font-size: 13px;
font-weight: lighter;
}
#toast-container > div,
#toast-container > div:hover {
box-shadow: 0 0 12px rgba(0, 0, 0, 0.1);
font-family: Helvetica, sans-serif;
}
#toast-container > div {
opacity: 0.95;
}
#merge-tag-reference-container {
text-align: left;
font-size: 14px;
}
#merge-tag-reference-container table th {
padding: 5px 20px 5px 0;
}
/* Fixed width sidebar */
#gjs > .gjs-editor > .gjs-cv-canvas { width: 100% !important; padding-right: 300px !important; }
#gjs-pn-views, #gjs-pn-views-container { width: 300px !important; }
#gjs-pn-options { right: 300px !important; }
#gjs-pn-commands { width: 100% !important; padding-right: 310px !important; }
/* Hide the fullscreen button - doesn't work from within our iFrame */
#gjs-pn-options .gjs-pn-btn.fa.fa-arrows-alt { display: none !important; }
</style>
{{> editor_navbar}}
<div id="gjs-wrapper">
<div id="gjs" style="height:0px; overflow:hidden">
{{#if resource.editorData.html}}
<style>{{{resource.editorData.css}}}</style>
{{{resource.editorData.html}}}
{{else}}
{{{resource.html}}}
{{/if}}
</div>
</div>
<!-- Test Newsletter Form -->
<form id="test-form" class="test-form" action="/editorapi/test?editor=grapejs" method="post" style="display: none">
<div class="putsmail-c">
<div class="gjs-sm-property" style="font-size: 10px">
Hello! I'm a placerholder message.
<span class="form-status" style="opacity: 0">
<i class="fa fa-refresh anim-spin" aria-hidden="true"></i>
</span>
</div>
</div>
<div class="gjs-sm-property">
<div class="gjs-field">
<span id="gjs-sm-input-holder">
<input type="email" name="email" placeholder="Email" required>
</span>
</div>
</div>
<div class="gjs-sm-property">
<div class="gjs-field">
<span id="gjs-sm-input-holder">
<input type="text" name="subject" placeholder="Subject" required value="Test {{resource.name}}">
</span>
</div>
</div>
<input type="hidden" name="html">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<button class="gjs-btn-prim gjs-btn-import" style="width: 100%">SEND</button>
</form>
<!-- Merge Tag Reference -->
{{#if resource.mergeTags}}
<div id="merge-tag-reference-container" style="display: none">
<table class="">
<thead>
<tr>
<th>
Merge tag
</th>
<th>
Description
</th>
</tr>
</thead>
<tbody>
{{#each resource.mergeTags}}
<tr>
<th>
[{{key}}]
</th>
<td>
{{value}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
<script>
$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': '{{csrfToken}}' } });
var editor = grapesjs.init({
height: '100%',
storageManager: {
type: 'none'
},
assetManager: {
assets: [],
upload: '/editorapi/upload?type={{type}}&id={{resource.id}}&editor={{resource.editorName}}',
uploadText: 'Drop images here or click to upload',
},
container : '#gjs',
fromElement: true,
plugins: ['gjs-preset-newsletter'],
pluginsOpts: {
'gjs-preset-newsletter': {
modalLabelImport: 'Paste all your code here below and click import',
modalLabelExport: 'Copy the code and use it wherever you want',
codeViewerTheme: 'material',
importPlaceholder: '<table class="table"><tr><td class="cell">Hello world!</td></tr></table>',
cellStyle: {
'font-size': '12px',
'font-weight': 300,
'vertical-align': 'top',
color: 'rgb(111, 119, 125)',
margin: 0,
padding: 0,
}
}
}
});
$.getJSON('/editorapi/upload?type={{type}}&id={{resource.id}}&editor={{resource.editorName}}', function(data) {
editor.AssetManager.add(data.files);
});
function getPreparedHtml() {
var imgs = [];
$('.gjs-pn-buttons > .gjs-pn-btn.fa.fa-desktop').click();
$('.gjs-editor > .gjs-cv-canvas > iframe.gjs-frame')
.contents()
.find('img')
.each(function() {
var src = $(this).attr('src');
var s = src.match(/\/editorapi\/img\?src=([^&]*)/);
var encodedSrc = (s && s[1]) || encodeURIComponent(src);
var dynamicSrc = '/editorapi/img?src=' + encodedSrc + '&method=resize&params=' + $(this).width() + '%2C' + $(this).height();
imgs.push({
cls: $(this).attr('class'),
dynamicSrc: dynamicSrc,
src: src,
});
});
var html = editor.runCommand('gjs-get-inlined-html');
imgs.forEach(function(img) {
html = html.replace(
'<img class="' + img.cls + '" src="' + img.src,
'<img class="' + img.cls + '" src="' + img.dynamicSrc
);
});
html = '<!doctype html><html><head><meta charset="utf-8"><title>{{resource.name}}</title></head><body>' + html + '</body></html>';
return html;
}
var mdlClass = 'gjs-mdl-dialog-sm';
var pnm = editor.Panels;
var cmdm = editor.Commands;
var testContainer = document.getElementById("test-form");
var contentEl = testContainer.querySelector('input[name=html]');
var md = editor.Modal;
cmdm.add('send-test', {
run(editor, sender) {
sender.set('active', 0);
var modalContent = md.getContentEl();
var mdlDialog = document.querySelector('.gjs-mdl-dialog');
// var cmdGetCode = cmdm.get('gjs-get-inlined-html');
// contentEl.value = cmdGetCode && cmdGetCode.run(editor);
contentEl.value = getPreparedHtml();
mdlDialog.className += ' ' + mdlClass;
testContainer.style.display = 'block';
md.setTitle('Test your Newsletter');
md.setContent(testContainer);
md.open();
md.getModel().once('change:open', function() {
mdlDialog.className = mdlDialog.className.replace(mdlClass, '');
//clean status
})
}
});
pnm.addButton('options', {
id: 'send-test',
className: 'fa fa-paper-plane',
command: 'send-test',
attributes: {
'title': 'Test Newsletter',
'data-tooltip-pos': 'bottom',
},
});
var statusFormElC = document.querySelector('.form-status');
var statusFormEl = document.querySelector('.form-status i');
var ajaxTest = ajaxable(testContainer, { headers: { 'X-CSRF-TOKEN': '{{csrfToken}}' } })
.onStart(function() {
statusFormEl.className = 'fa fa-refresh anim-spin';
statusFormElC.style.opacity = '1';
statusFormElC.className = 'form-status';
})
.onResponse(function(res) {
if (res.data) {
statusFormElC.style.opacity = '0';
statusFormEl.removeAttribute('data-tooltip');
md.close();
} else if (res.errors) {
statusFormEl.className = 'fa fa-exclamation-circle';
statusFormEl.setAttribute('data-tooltip', res.errors);
statusFormElC.className = 'form-status text-danger';
}
});
cmdm.add('tlb-edit', {
run: function(ed) {
alert('No image editor installed');
},
});
// Add Merge Tag Reference command
var mergeTagReferenceContainer = document.getElementById('merge-tag-reference-container');
cmdm.add('open-merge-tag-reference', {
run(editor, sender) {
sender.set('active', 0);
var mdlDialog = document.querySelector('.gjs-mdl-dialog');
mdlDialog.className += ' gjs-mdl-dialog-lg';
mergeTagReferenceContainer.style.display = 'block';
md.setTitle('Merge tag reference');
md.setContent(mergeTagReferenceContainer);
md.open();
md.getModel().once('change:open', function() {
mdlDialog.className = mdlDialog.className.replace('gjs-mdl-dialog-lg', '');
});
}
});
pnm.addButton('options', {
id: 'view-merge-tag-reference',
className: 'fa fa-tags',
command: 'open-merge-tag-reference',
attributes: {
'title': 'Merge tag reference',
'data-tooltip-pos': 'bottom',
},
});
// Simple warn notifier
var origWarn = console.warn;
toastr.options = {
closeButton: true,
preventDuplicates: true,
showDuration: 250,
hideDuration: 150
};
console.warn = function(msg) {
toastr.warning(msg);
origWarn(msg);
};
$(document).ready(function() {
// Beautify tooltips
$('*[title]').each(function() {
var el = $(this);
var title = el.attr('title').trim();
if (title) {
el.attr('data-tooltip', el.attr('title'));
el.attr('title', '');
}
});
// Remember testmail address
var isValidEmail = function(email) {
return /\S+@\S+\.\S+/.test(email);
};
var email = localStorage.getItem('testemail');
isValidEmail(email) && $('#test-form input[name=email]').val(email);
$(document).on('submit', '#test-form', function() {
var email = $('#test-form input[name=email]').val();
isValidEmail(email) && localStorage.setItem('testemail', email);
});
// Save and Close Buttons
window.bridge = window.bridge || {};
$('#mt-close').on('click', function() {
if (confirm('Unsaved changes will be lost. Close now?') === true) {
window.bridge.exit
? window.bridge.exit()
: window.location.href = '/{{type}}s/edit/{{resource.id}}?tab=template';
}
});
$('#mt-save').on('click', function() {
if ($(this).hasClass('busy')) {
return;
}
$(this).addClass('busy');
var html = getPreparedHtml();
$.post('/editorapi/update?type={{type}}&editor={{resource.editorName}}', {
id: {{resource.id}},
name: '{{resource.name}}',
{{#if resource.list}} list: {{resource.list}}, {{/if}}
html: html,
editorData: JSON.stringify({
template: '{{resource.editorData.template}}',
css: editor.getCss(),
html: editor.getHtml(),
style: editor.getStyle(),
components: editor.getComponents(),
}),
}, null, 'html')
.success(function() {
window.bridge.lastSavedHtml = html;
})
.fail(function(data) {
alert(data.responseText || 'An error occured while saving the document');
})
.always(function() {
setTimeout(function() {
$(this).removeClass('busy');
}.bind(this), 500); // Don't save too fast
}.bind(this));
});
});
</script>

View file

@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>GrapesJS Newsletter Editor</title>
<link rel="stylesheet" href="/grapejs/dist/css/grapes.min.css">
<link rel="stylesheet" href="/grapejs/dist/css/material.css">
<link rel="stylesheet" href="/grapejs/dist/css/tooltip.css">
<link rel="stylesheet" href="/grapejs/dist/css/toastr.min.css">
<link rel="stylesheet" href="/grapejs/dist/css/grapesjs-preset-newsletter.css">
<script src="/javascript/jquery-2.2.1.min.js"></script>
<script src="/grapejs/dist/js/grapes.min.js"></script>
<script src="/grapejs/dist/js/grapesjs-preset-newsletter.min.js"></script>
<script src="/grapejs/dist/js/toastr.min.js"></script>
<script src="/grapejs/dist/js/ajaxable.min.js"></script>
</head>
<body>
{{{body}}}
</body>
</html>

286
views/mosaico/editor.hbs Normal file
View file

@ -0,0 +1,286 @@
<style>
html,
body {
margin: 0;
padding: 0;
}
body {
position: absolute;
top: 34px;
bottom: 0;
width: 100%;
}
#mt-navbar {
position: absolute;
width: 100%;
top: -34px;
}
#mt-navbar .mt-logo {
padding-left: 10px;
}
#merge-tag-reference-container * {
background: none;
text-align: left;
border: none;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
#merge-tag-reference-container th,
#merge-tag-reference-container td {
padding: 5px 10px 5px 0;
height: 2em;
}
@media screen and (max-width: 1175px) {
a.ui-button[title='Download template'] {
display: none !important;
}
}
@media screen and (max-width: 1020px) {
#btnMergeReference {
display: none !important;
}
}
</style>
{{> editor_navbar}}
{{#if resource.mergeTags}}
<div id="merge-tag-reference-container" title="{{#translate}}Merge tag reference{{/translate}}" style="display: none">
<table class="">
<thead>
<tr>
<th>
{{#translate}}Merge tag{{/translate}}
</th>
<th>
{{#translate}}Description{{/translate}}
</th>
</tr>
</thead>
<tbody>
{{#each resource.mergeTags}}
<tr>
<th>
[{{key}}]
</th>
<td>
{{value}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
<script>
$(function() {
if (!Mosaico.isCompatible()) {
alert('Update your browser!');
return;
}
window.bridge = window.bridge || {};
$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': '{{csrfToken}}' } });
var uploadHost = window.location.protocol + '//' + window.location.host;
var plugins = [];
var config = {
// Mosaico needs absolute URLs
imgProcessorBackend: uploadHost + '/editorapi/img',
titleToken: '{{#translate}}MOSAICO Responsive Email Designer{{/translate}}',
fileuploadConfig: {
url: uploadHost + '/editorapi/upload?type={{type}}&id={{resource.id}}&editor={{resource.editorName}}',
},
{{#if languageStrings}}
strings: {{{languageStrings}}},
{{/if}}
};
// Save Button
plugins.push(function(viewModel) {
$(document).ready(function() {
$('#mt-save').on('click', function() {
if ($(this).hasClass('busy')) {
return;
}
$(this).addClass('busy');
var html = viewModel.exportHTML();
$.post('/editorapi/update?type={{type}}&editor={{resource.editorName}}', {
id: {{resource.id}},
name: '{{resource.name}}',
{{#if resource.list}} list: {{resource.list}}, {{/if}}
html: html,
editorData: JSON.stringify({
template: '{{resource.editorData.template}}',
model: viewModel.exportJSON(),
metadata: viewModel.exportMetadata(),
}),
}, null, 'html')
.success(function() {
setTimeout(function() {
window.bridge.lastSavedHtml = html;
viewModel.notifier.success('{{#translate}}Sucessfully saved{{/translate}}');
$(this).removeClass('busy');
}.bind(this), 500); // Don't save too fast
}.bind(this))
.fail(function(data) {
viewModel.notifier.error(data.responseText || '{{#translate}}An error occured while saving the document{{/translate}}');
$(this).removeClass('busy');
}.bind(this))
});
});
});
// Close Button
plugins.push(function(viewModel) {
$(document).ready(function() {
$('#mt-close').on('click', function() {
if (confirm('{{#translate}}Unsaved changes will be lost. Close now?{{/translate}}') === true) {
window.bridge.exit
? window.bridge.exit()
: window.location.href = '/{{type}}s/edit/{{resource.id}}?tab=template';
}
});
});
});
// Download Button
plugins.push(function(viewModel) {
viewModel.download = {
name: 'Download', // Translated by Mosaico
enabled: ko.observable(true),
execute: function() {
viewModel.download.enabled(false);
viewModel.notifier.info(viewModel.t('Downloading...'));
viewModel.exportHTMLtoTextarea('#downloadHtmlTextarea');
var $downloadForm = $('#downloadForm');
$downloadForm.attr('action', '/editorapi/download?editor={{resource.editorName}}');
if (!$downloadForm.find('input[name=_csrf]').length) {
$('<input>').attr({
type: 'hidden',
name: '_csrf',
value: '{{csrfToken}}',
}).appendTo($downloadForm);
}
$downloadForm.submit();
viewModel.download.enabled(true);
},
};
});
// Test E-Mail Button
plugins.push(function(viewModel) {
viewModel.test = {
name: 'Test', // Translated by Mosaico
enabled: ko.observable(true),
isValidEmail: function(email) {
return /\S+@\S+\.\S+/.test(email);
},
execute: function() {
viewModel.test.enabled(false);
var email = localStorage.getItem('testemail');
if (!viewModel.test.isValidEmail(email)) {
email = viewModel.t('Insert here the recipient email address');
}
email = prompt(viewModel.t('Test email address'), email);
if (viewModel.test.isValidEmail(email)) {
localStorage.setItem('testemail', email);
$.post('/editorapi/test?editor={{resource.editorName}}', {
email: email,
subject: '[test] {{resource.name}}',
html: viewModel.exportHTML(),
}, null, 'html')
.success(function() {
viewModel.notifier.success(viewModel.t('Test email sent...'));
})
.fail(function() {
viewModel.notifier.error(viewModel.t('Unexpected error talking to server: contact us!'));
})
.always(function() {
viewModel.test.enabled(true);
});
} else {
viewModel.test.enabled(true);
email !== null && alert(viewModel.t('Invalid email address'));
}
}
};
});
// Merge Tag Reference
plugins.push(function(viewModel) {
var addButton = function() {
var $toobarRightButtons = $('#toolbar .rightButtons');
if (!$toobarRightButtons.length) {
setTimeout(addButton, 50);
return;
}
$('<a title="{{#translate}}Tags{{/translate}}" id="btnMergeReference" class="ui-button ui-corner-all ui-widget" role="button">\
<span class="ui-button-icon ui-icon fa fa-fw fa-tags"></span>\
<span class="ui-button-icon-space"></span>{{#translate}}Tags{{/translate}}\
</a>')
.prependTo($toobarRightButtons)
.css('color', 'white', '!important')
.on('click', function() {
$('#merge-tag-reference-container').dialog({
modal: true,
width: 'auto',
draggable: true,
resizable: false,
appendTo: '#mo-body',
closeText: '',
});
});
};
addButton();
});
// (Custom) HTML postRenderers
plugins.push(function(viewModel) {
viewModel.originalExportHTML = viewModel.exportHTML;
viewModel.exportHTML = function() {
var html = viewModel.originalExportHTML();
window.mosaicoHTMLPostRenderers.forEach(function(postRender) {
html = postRender(html);
});
return html;
};
});
// Finally start Mosaico
Mosaico.start(
config,
uploadHost + '/{{resource.editorName}}/templates/{{resource.editorData.template}}/index.html',
{{#if resource.editorData.metadata}} {{{resource.editorData.metadata}}} {{else}} undefined {{/if}},
{{#if resource.editorData.model}} {{{resource.editorData.model}}} {{else}} undefined {{/if}},
plugins.concat(window.mosaicoPlugins)
);
});
</script>

View file

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=1024, initial-scale=1">
<script src="dist/vendor/jquery.min.js"></script>
<script src="dist/vendor/knockout.js"></script>
<script src="dist/vendor/jquery-ui.min.js"></script>
<script src="dist/vendor/jquery.ui.touch-punch.min.js"></script>
<script src="dist/vendor/load-image.all.min.js"></script>
<script src="dist/vendor/canvas-to-blob.min.js"></script>
<script src="dist/vendor/jquery.iframe-transport.js"></script>
<script src="dist/vendor/jquery.fileupload.js"></script>
<script src="dist/vendor/jquery.fileupload-process.js"></script>
<script src="dist/vendor/jquery.fileupload-image.js"></script>
<script src="dist/vendor/jquery.fileupload-validate.js"></script>
<script src="dist/vendor/knockout-jqueryui.min.js"></script>
<script src="dist/vendor/tinymce.min.js"></script>
<script src="dist/mosaico.min.js?v=0.16"></script>
<script> window.mosaicoPlugins = []; </script>
<script> window.mosaicoHTMLPostRenderers = []; </script>
{{#if editorConfig.customscripts}}
{{#each editorConfig.customscripts}}
<script src="custom/{{this}}"></script>
{{/each}}
{{/if}}
<link rel="stylesheet" href="dist/mosaico-material.min.css?v=0.10" />
<link rel="stylesheet" href="dist/vendor/notoregular/stylesheet.css" />
</head>
<body class="mo-standalone">
{{{body}}}
</body>
</html>

View file

@ -3,36 +3,45 @@
overflow: hidden;
}
#editor-frame,
#editor-frame-spinner {
border: none;
#editor-frame-loader {
position: fixed;
z-index: 10000;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
#editor-frame-spinner {
#editor-frame-loader {
z-index: 10001;
background: white;
background: #eaeced;
}
#editor-frame-spinner div {
width: 120px;
height: 120px;
margin-top: -60px;
margin-left: -60px;
#editor-frame-loader div {
position: absolute;
top: 50%;
left: 50%;
width: 62px; margin-left: -31px;
height: 72px; margin-top: -36px;
background: url('/mailtrain-header.png');
-webkit-animation: pulsate 1.2s ease-out;
animation: pulsate 1.2s ease-out;
-webkit-animation-iteration-count: infinite;
animation-iteration-count: infinite;
}
#editor-frame-spinner span:before {
font-size: 120px;
color: #efefef;
@-webkit-keyframes pulsate {
0% { -webkit-transform: scale(0.1, 0.1); opacity: 0.5; }
70% { opacity: 1.0; }
100% { -webkit-transform: scale(1.2, 1.2); opacity: 0.0; }
}
@keyframes pulsate {
0% { transform: scale(0.1, 0.1); opacity: 0.5; }
70% { opacity: 1.0; }
100% { transform: scale(1.2, 1.2); opacity: 0.0; }
}
</style>
<div id="editor-frame-spinner" style="display: none;">
<div><span class="glyphicon glyphicon-refresh spinning"></span></div>
<div id="editor-frame-loader" style="display: none;">
<div></div>
</div>
<script>
@ -51,13 +60,13 @@
closeEditor();
}
setTimeout(function() {
$('#editor-frame-spinner').hide();
}, 200);
$('#editor-frame-loader').hide();
}, 300);
});
var openEditor = function() {
$('body').addClass('noscroll');
$('#editor-frame-spinner').show();
$('#editor-frame-loader').show();
$editorFrame.appendTo($('body'));
}
@ -67,7 +76,7 @@
if (editorWindow.bridge.lastSavedHtml) {
$('#template-html').val(editorWindow.bridge.lastSavedHtml);
$('#html-preview-frame').attr('srcdoc', editorWindow.bridge.lastSavedHtml);
$('#html-preview').show(); // it's hidden when there's no html
$('#html-preview').show();
}
// Reload to discard unsaved changes
$editorFrame.attr('src', '/{{editorName}}/editor?id={{id}}&type=' + type + '&cb=' + Math.random());

View file

@ -0,0 +1,44 @@
<style>
#mt-navbar {
background: #DE4320;
overflow: hidden;
height: 34px;
}
.mt-logo {
height: 24px;
padding: 5px 0 5px 35px;
-webkit-filter: brightness(0) invert(1);
filter: brightness(0) invert(1);
}
.mt-btn {
display: block;
float: right;
width: 150px;
line-height: 34px;
text-align: center;
color: white;
font-size: 14px;
font-weight: bold;
font-family: sans-serif;
cursor: pointer;
border-left: 1px solid #972E15;
}
.mt-btn:hover {
background: #972E15;
}
#mt-save span:after {
content: '{{#translate}}SAVE{{/translate}}';
}
#mt-save.busy span:after {
content: '{{#translate}}SAVING{{/translate}}';
}
#mt-save.busy {
background: #972E15;
}
</style>
<div id="mt-navbar">
<img class="mt-logo" src="/mailtrain-header.png">
<a id="mt-close" class="mt-btn">{{#translate}}CLOSE{{/translate}}</a>
<a id="mt-save" class="mt-btn"><span></span></a>
</div>

View file

@ -0,0 +1,27 @@
<style>
#btn-open-grapejs {
background-color: #4E6A8A;
border-color: #4E6A8A;
padding-left: 36px;
padding-right: 36px;
}
#btn-open-grapejs:hover {
background-color: #394C61;
border-color: #394C61;
}
</style>
{{> html_to_text}}
<input type="hidden" id="template-html" value="{{html}}">
<div class="form-group">
<label class="col-sm-2 control-label">{{#translate}}Template content (HTML){{/translate}}</label>
<div class="col-sm-10">
<a class="btn btn-lg btn-success" role="button" id="btn-open-grapejs">{{#translate}}Open GrapeJS{{/translate}}</a>
</div>
</div>
{{> html_preview}}
{{> editor_bridge}}

View file

@ -0,0 +1,41 @@
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<span class="help-block">
{{#translate}}To extract the text from HTML click <a id="html-to-plaintext-btn" role="button">here</a>.{{/translate}}
<span id="html-to-plaintext-spinner" class="glyphicon glyphicon-refresh spinning hidden"></span>
{{#translate}}Please note that your existing plaintext in the field above will be overwritten. This feature uses the <a href="http://premailer.dialect.ca/api" target="_blank" rel="noreferrer">Premailer API</a>, a third party service. Their Terms of Service and Privacy Policy apply.{{/translate}}
<span>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': '{{csrfToken}}' } });
var $spinner = $('#html-to-plaintext-spinner');
$('#html-to-plaintext-btn').on('click', function() {
var html = $('#template-html').val();
if (!html) {
alert('Missing HTML content');
return;
}
if (!$spinner.hasClass('hidden')) {
return;
}
$spinner.removeClass('hidden');
$.post('/editorapi/html-to-text?editor={{editorName}}', { html: html }, null, 'html')
.success(function(data) {
$('#template-text').val(data);
})
.fail(function(data) {
alert(data.responseText || '{{#translate}}An error occurred while talking to the server{{/translate}}');
})
.always(function() {
$spinner.addClass('hidden');
});
});
});
</script>

View file

@ -0,0 +1,27 @@
<style>
#btn-open-mosaico {
background-color: #4E6A8A;
border-color: #4E6A8A;
padding-left: 36px;
padding-right: 36px;
}
#btn-open-mosaico:hover {
background-color: #394C61;
border-color: #394C61;
}
</style>
{{> html_to_text}}
<input type="hidden" id="template-html" value="{{html}}">
<div class="form-group">
<label class="col-sm-2 control-label">{{#translate}}Template content (HTML){{/translate}}</label>
<div class="col-sm-10">
<a class="btn btn-lg btn-success" role="button" id="btn-open-mosaico">{{#translate}}Open Mosaico{{/translate}}</a>
</div>
</div>
{{> html_preview}}
{{> editor_bridge}}