Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -0,0 +1 @@
.code-panel{text-align:left;font-size:1rem;height:100%;display:flex;flex-direction:column}.code-panel section{flex:1}.code-panel section .codepanel-separator{display:flex;justify-content:space-between;padding-left:.6rem;padding-right:.6rem}.code-panel section .codepanel-label{line-height:20px;font-size:13px;color:#aaa;user-select:none;text-transform:uppercase}.code-panel section button{background-color:#d6d6d6}.gutter{cursor:ns-resize;position:relative;background-color:rgba(0,0,0,0.2)}.gutter:after{content:'';display:block;height:8px;width:100%;position:absolute;top:-3px;z-index:150}.code-panel .CodeMirror{height:calc(100% - 20px)}.gjs-pn-views{border-left:1px solid rgba(0,0,0,0.2);border-bottom:0}.gjs-pn-views-container{box-shadow:initial;top:40px;padding-top:0;height:calc(100% - 40px)}.gjs-pn-views-container,.gjs-cv-canvas{transition:width .3s ease-in-out}

View File

@@ -0,0 +1,85 @@
export default class AssetService {
constructor() {
this.uploadPath = '/s/grapesjsbuilder/upload';
this.deletePath = '/s/grapesjsbuilder/delete';
this.assetsPath = '/s/grapesjsbuilder/media';
this.assetsPage = 1;
this.totalPages = null;
}
/**
* Get the path for uploading assets
* @returns {string} The upload path
*/
getUploadPath() {
return this.uploadPath;
}
/**
* Get the path for deleting assets
* @returns {string} The delete path
*/
getDeletePath() {
return this.deletePath;
};
/**
* Get assets path with current page
* @returns {string}
*/
getAssetsPath() {
return `${this.assetsPath}?page=${this.assetsPage}`;
}
/**
* Fetch assets from the server
* @param {string} assetsPath
* @returns {Promise<Object>}
*/
async fetchAssets(assetsPath) {
try {
const response = await fetch(assetsPath);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const result = await response.json();
if (result.totalPages !== undefined) {
this.totalPages = result.totalPages;
}
return result;
} catch (error) {
console.error('Error fetching assets:', error);
throw error;
}
}
/**
* Get assets for the first page
* @returns {Promise<Object>}
*/
async getAssetsXhr() {
this.assetsPage = 1;
return this.fetchAssets(this.getAssetsPath());
}
/**
* Get assets for the next page
* @returns {Promise<Object>|null}
*/
async getAssetsNextPageXhr() {
if (this.hasLoadedAllAssets()) {
return null;
}
this.assetsPage++;
return this.fetchAssets(this.getAssetsPath());
}
/**
* Check if all assets have been loaded
* @returns {boolean}
*/
hasLoadedAllAssets() {
return this.totalPages !== null && this.assetsPage >= this.totalPages;
}
}

View File

@@ -0,0 +1,190 @@
import AssetService from './asset.service';
import BuilderService from './builder.service';
// all css get combined into one builder.css and automatically loaded via js/parcel
import 'grapesjs/dist/css/grapes.min.css';
import './grapesjs-custom.css';
/**
* Launch builder
*
* @param formName
*/
function launchBuilderGrapesjs(formName) {
if (useBuilderForCodeMode() === false) {
return;
}
Mautic.showChangeThemeWarning = true;
// Prepare HTML
mQuery('html').css('font-size', '100%');
mQuery('body').css('overflow-y', 'hidden');
mQuery('.builder-panel').css('padding', 0);
mQuery('.builder-panel').css('display', 'block');
const $builder = mQuery('.builder');
$builder.addClass('builder-active').removeClass('hide');
const assetService = new AssetService();
const builder = new BuilderService(assetService);
// Initialize GrapesJS
builder.initGrapesJS(formName);
// trigger show event on DOM element
$builder.trigger('builder:show', [builder.editor])
// trigger show event on editor instance
builder.editor.trigger('show');
// Load and add assets
(async () => {
try {
const result = await assetService.getAssetsXhr();
builder.editor.AssetManager.add(result.data);
} catch (error) {
console.error('Error loading initial assets:', error);
}
})();
}
/**
* The user acknowledges the risk before editing an email or landing page created in Code Mode in the Builder
*/
function useBuilderForCodeMode() {
const theme = mQuery('.theme-selected').find('[data-theme]').attr('data-theme');
const isCodeMode = theme === 'mautic_code_mode';
if (isCodeMode) {
if (confirm(Mautic.translate('grapesjsbuilder.builder.warning.code_mode')) === false) {
return false;
}
}
return true;
}
/**
* Set theme's HTML
*
* @param theme
*/
function setThemeHtml(theme) {
BuilderService.setupButtonLoadingIndicator(true);
// Load template and fill field
mQuery.ajax({
url: mQuery('#builder_url').val(),
data: `template=${theme}`,
dataType: 'json',
success(response) {
const textareaHtml = mQuery('textarea.builder-html');
const textareaMjml = mQuery('textarea.builder-mjml');
textareaHtml.val(response.templateHtml);
if (typeof textareaMjml !== 'undefined') {
textareaMjml.val(response.templateMjml);
}
// If MJML template, generate HTML before save
if (!textareaHtml.val().length && textareaMjml.val().length) {
const assetService = new AssetService();
const builder = new BuilderService(assetService);
textareaHtml.val(builder.mjmlToHtml(response.templateMjml));
}
},
error(request, textStatus) {
console.log(`setThemeHtml - Request failed: ${textStatus}`);
},
complete() {
BuilderService.setupButtonLoadingIndicator(false);
},
});
}
/**
* The builder button to launch GrapesJS will be disabled when the code mode theme is selected
*
* @param theme
*/
function switchBuilderButton(theme) {
const builderButton = mQuery('.btn-builder');
const mEmailBuilderButton = mQuery('#emailform_buttons_builder_toolbar_mobile');
const mPageBuilderButton = mQuery('#page_buttons_builder_toolbar_mobile');
const isCodeMode = theme === 'mautic_code_mode';
builderButton.attr('disabled', isCodeMode);
if (isCodeMode) {
mPageBuilderButton.addClass('link-is-disabled');
mEmailBuilderButton.addClass('link-is-disabled');
mPageBuilderButton.parent().addClass('is-not-allowed');
mEmailBuilderButton.parent().addClass('is-not-allowed');
} else {
mPageBuilderButton.removeClass('link-is-disabled');
mEmailBuilderButton.removeClass('link-is-disabled');
mPageBuilderButton.parent().removeClass('is-not-allowed');
mEmailBuilderButton.parent().removeClass('is-not-allowed');
}
}
/**
* The textarea with the HTML source will be displayed if the code mode theme is selected
*
* @param theme
*/
function switchCustomHtml(theme) {
const customHtmlRow = mQuery('#custom-html-row');
const isPageMode = mQuery('[name="page"]').length !== 0;
const isCodeMode = theme === 'mautic_code_mode';
const advancedTab = isPageMode ? mQuery('#advanced-tab') : null;
if (isCodeMode === true) {
customHtmlRow.removeClass('hidden');
isPageMode && advancedTab.removeClass('hidden');
} else {
customHtmlRow.addClass('hidden');
isPageMode && advancedTab.addClass('hidden');
}
}
/**
* Initialize original Mautic theme selection with grapejs specific modifications
*/
function initSelectThemeGrapesjs(parentInitSelectTheme) {
function childInitSelectTheme(themeField) {
const builderUrl = mQuery('#builder_url');
let url;
switchBuilderButton(themeField.val());
switchCustomHtml(themeField.val());
// Replace Mautic URL by plugin URL
if (builderUrl.length) {
if (builderUrl.val().indexOf('pages') !== -1) {
url = builderUrl.val().replace('s/pages/builder', 's/grapesjsbuilder/page');
} else {
url = builderUrl.val().replace('s/emails/builder', 's/grapesjsbuilder/email');
}
builderUrl.val(url);
}
// Launch original Mautic.initSelectTheme function
parentInitSelectTheme(themeField);
mQuery('[data-theme]').click((event) => {
const target = mQuery(event.target);
const theme = target.closest('[data-theme]').attr('data-theme');
switchBuilderButton(theme);
switchCustomHtml(theme);
});
}
return childInitSelectTheme;
}
Mautic.launchBuilder = launchBuilderGrapesjs;
Mautic.initSelectTheme = initSelectThemeGrapesjs(Mautic.initSelectTheme);
Mautic.setThemeHtml = setThemeHtml;

View File

@@ -0,0 +1,550 @@
import grapesjs from 'grapesjs';
import grapesjsmjml from 'grapesjs-mjml';
import grapesjsnewsletter from 'grapesjs-preset-newsletter';
import grapesjswebpage from 'grapesjs-preset-webpage';
import grapesjsblocksbasic from 'grapesjs-blocks-basic';
import grapesjscomponentcountdown from 'grapesjs-component-countdown';
import grapesjsnavbar from 'grapesjs-navbar';
import grapesjscustomcode from 'grapesjs-custom-code';
import grapesjstouch from 'grapesjs-touch';
import grapesjstuiimageeditor from 'grapesjs-tui-image-editor';
import grapesjsstylebg from 'grapesjs-style-bg';
import grapesjspostcss from 'grapesjs-parser-postcss';
import grapesjsckeditor from './plugins/grapesjs.ckeditor';
import contentService from 'grapesjs-preset-mautic/dist/content.service';
import grapesjsmautic from 'grapesjs-preset-mautic';
import editorFontsService from 'grapesjs-preset-mautic/dist/editorFonts/editorFonts.service';
import StorageService from './storage.service';
// for local dev
// import contentService from '../../../../../../grapesjs-preset-mautic/src/content.service';
// import grapesjsmautic from '../../../../../../grapesjs-preset-mautic/src';
import CodeModeButton from './codeMode/codeMode.button';
import CompCopyPaste from './commands/compCopyPaste';
import MjmlService from 'grapesjs-preset-mautic/dist/mjml/mjml.service';
export default class BuilderService {
editor;
storageService;
assetService;
/**
* @param {AssetService} assetService
*/
constructor(assetService) {
this.assetService = assetService;
}
/**
* Initialize GrapesJsBuilder
*
* @param object
*/
setListeners() {
if (!this.editor) {
throw Error('No editor found');
}
// Why would we not want to keep the history?
//
// this.editor.on('load', () => {
// const um = this.editor.UndoManager;
// // Clear stack of undo/redo
// um.clear();
// });
const keymaps = this.editor.Keymaps;
let allKeymaps;
if (mauticEditorFonts) {
this.editor.on('load', () => editorFontsService.loadEditorFonts(this.editor));
}
this.editor.on('modal:open', () => {
// Save all keyboard shortcuts
allKeymaps = { ...keymaps.getAll() };
// Remove keyboard shortcuts to prevent launch behind popup
keymaps.removeAll();
});
this.editor.on('modal:close', () => {
// ReMap keyboard shortcuts on modal close
Object.keys(allKeymaps).map((objectKey) => {
const shortcut = allKeymaps[objectKey];
keymaps.add(shortcut.id, shortcut.keys, shortcut.handler);
return keymaps;
});
});
this.editor.on('asset:remove', (response) => {
// Delete file on server
mQuery.ajax({
url: this.assetService.getDeletePath(),
data: { filename: response.getFilename() },
});
});
this.editor.on('asset:upload:error', (error) => {
Mautic.setFlashes(Mautic.addErrorFlashMessage(error));
});
this.editor.on('asset:open', () => {
const editor = this.editor;
const assetsService = this.assetService;
const assetsContainer = document.querySelector('.gjs-am-assets');
const $assetsSpinner = document.createElement('div');
$assetsSpinner.className = 'gjs-assets-spinner';
$assetsSpinner.innerHTML = '<i class="ri-loader-3-line ri-spin"></i>';
if (assetsContainer) {
let isLoading = false;
const loadNextPage = async () => {
if (isLoading) return;
isLoading = true;
assetsContainer.appendChild($assetsSpinner);
try {
const result = await assetsService.getAssetsNextPageXhr();
if (result) {
const assetManager = editor.AssetManager;
const currentAssets = assetManager.getAll().models;
const newAssets = result.data;
// Combine current assets with new assets
const combinedAssets = [...currentAssets, ...newAssets];
// Reset the entire collection with combined assets
assetManager.getAll().reset(combinedAssets);
assetManager.render();
}
} catch (error) {
console.error('Error loading next page of assets:', error);
} finally {
isLoading = false;
}
};
assetsContainer.addEventListener('scroll', function() {
const hasScrolledToBottom = this.scrollTop + this.clientHeight >= this.scrollHeight - 5;
if (hasScrolledToBottom && !assetsService.hasLoadedAllAssets()) {
loadNextPage();
}
});
} else {
console.warn('Element with class "gjs-am-assets" not found');
}
});
const triggerBuilderHide = () => {
// trigger hide event on DOM element
mQuery('.builder').trigger('builder:hide', [this.editor]);
// trigger hide event on editor instance
this.editor.trigger('hide');
};
this.editor.on('run:mautic-editor-page-html-close', triggerBuilderHide);
this.editor.on('run:mautic-editor-email-html-close', triggerBuilderHide);
this.editor.on('run:mautic-editor-email-mjml-close', triggerBuilderHide);
// add offset to flashes container for better UI visibility when builder is on
this.editor.on('show', () => mQuery('#flashes').addClass('alert-offset'));
this.editor.on('hide', () => mQuery('#flashes').removeClass('alert-offset'));
}
/**
* Initialize the grapesjs build in the
* correct mode
*/
initGrapesJS(object) {
// grapesjs-custom-plugins: add globally defined mautic-grapesjs-plugins using name as pluginId for the plugin-function
if (window.MauticGrapesJsPlugins) {
window.MauticGrapesJsPlugins.forEach((item) => {
if (!item.name) {
console.warn('A name is required for Mautic-GrapesJs plugins in window.MauticGrapesJsPlugins. Registration skipped!');
return;
}
if (typeof item.plugin !== 'function') {
console.warn('The Mautic-GrapesJs plugin must be a function in window.MauticGrapesJsPlugins. Registration skipped!');
return;
}
grapesjs.plugins.add(item.name, item.plugin);
});
}
// disable mautic global shortcuts
Mousetrap.reset();
if (object === 'page') {
this.editor = this.initPage();
} else if (object === 'emailform') {
if (MjmlService.getOriginalContentMjml()) {
this.editor = this.initEmailMjml();
} else {
this.editor = this.initEmailHtml();
}
} else {
throw Error(`Not supported builder type: ${object}`);
}
// add code mode button
// @todo: only show button if configured: sourceEdit: 1,
const codeModeButton = new CodeModeButton(this.editor);
codeModeButton.addCommand();
codeModeButton.addButton();
/**
* Add command that will allow users
* to copy paste component across tabs
*/
new CompCopyPaste(this.editor).addCommand();
this.storageService = new StorageService(this.editor, object);
this.setListeners();
}
static getMauticConf(mode) {
return {
mode,
};
}
static getCkeConf(tokenCallback) {
const ckEditorToolbarOptions = ['undo', 'redo', '|', 'bold','italic', 'underline','strikethrough', '|', 'fontSize','fontFamily','fontColor','fontBackgroundColor', '|' ,'alignment','outdent', 'indent', '|', 'blockQuote', 'insertTable', '|', 'bulletedList','numberedList', '|', 'link', '|', 'TokenPlugin'];
return Mautic.GetCkEditorConfigOptions(ckEditorToolbarOptions, tokenCallback);
}
/**
* Initialize the builder in the landingapge mode
*/
initPage() {
// Launch GrapesJS with body part
this.editor = grapesjs.init({
clearOnRender: true,
container: '.builder-panel',
components: contentService.getOriginalContentHtml().body.innerHTML,
height: '100%',
canvas: {
styles: contentService.getStyles(),
},
storageManager: false, // https://grapesjs.com/docs/modules/Storage.html#basic-configuration
assetManager: this.getAssetManagerConf(),
styleManager: {
clearProperties: true, // Temp fix https://github.com/artf/grapesjs-preset-webpage/issues/27
},
plugins: [
// partially copied from: https://github.com/GrapesJS/grapesjs/blob/gh-pages/demo.html
grapesjswebpage,
grapesjspostcss,
grapesjsmautic,
grapesjsckeditor,
grapesjsblocksbasic,
grapesjscomponentcountdown,
grapesjsnavbar,
grapesjscustomcode,
grapesjstouch,
grapesjspostcss,
grapesjstuiimageeditor,
grapesjsstylebg,
...BuilderService.getPluginNames('page'), // grapesjs-custom-plugins: load custom plugins by their name
],
pluginsOpts: {
[grapesjswebpage]: {
formsOpts: false,
useCustomTheme: false,
},
grapesjsmautic: BuilderService.getMauticConf('page-html'),
[grapesjsckeditor]: BuilderService.getCkeConf('page:getBuilderTokens'),
...BuilderService.getPluginOptions('page'), // grapesjs-custom-plugins: add the plugin-options
},
});
this.moveBlocksPage();
return this.editor;
}
mjmlToHtml(mjml) {
const converted = MjmlService.mjmlToHtml(mjml);
if (0 === converted.errors.length) {
return converted.html;
}
return '';
}
initEmailMjml() {
const components = MjmlService.getOriginalContentMjml();
// validate
MjmlService.mjmlToHtml(components);
const styles = [
`${mauticBaseUrl}plugins/GrapesJsBuilderBundle/Assets/library/js/grapesjs-editor.css`
];
this.editor = grapesjs.init({
selectorManager: {
componentFirst: true,
},
avoidInlineStyle: false, // TEMP: fixes issue with disappearing inline styles
forceClass: false, // create new styles if there are some already on the element: https://github.com/GrapesJS/grapesjs/issues/1531
clearOnRender: true,
container: '.builder-panel',
height: '100%',
canvas: {
styles,
},
domComponents: {
// disable all except link components
disableTextInnerChilds: (child) => !child.is('link'), // https://github.com/GrapesJS/grapesjs/releases/tag/v0.21.2
},
storageManager: false,
assetManager: this.getAssetManagerConf(),
plugins: [grapesjsmjml, grapesjspostcss, grapesjsmautic, grapesjsckeditor, ...BuilderService.getPluginNames('email-mjml')],
pluginsOpts: {
[grapesjsmjml]: {
hideSelector: false,
custom: false,
useCustomTheme: false,
},
grapesjsmautic: BuilderService.getMauticConf('email-mjml'),
[grapesjsckeditor]: BuilderService.getCkeConf('email:getBuilderTokens'),
...BuilderService.getPluginOptions('email-mjml'),
},
});
this.unsetComponentVoidTypes(this.editor);
this.editor.setComponents(components);
// Reinitialize the content after parsing MJML.
// This can be removed once the issue with self-closing tags is resolved in grapesjs-mjml.
// See: https://github.com/GrapesJS/mjml/issues/149
const parsedContent = MjmlService.getEditorMjmlContent(this.editor);
this.editor.setComponents(parsedContent);
this.editor.BlockManager.get('mj-button').set({
content: '<mj-button href="https://">Button</mj-button>',
});
this.removeSelectedElementsEmailMjml();
return this.editor;
}
unsetComponentVoidTypes(editor) {
// Support for self-closing components is temporarily disabled due to parsing issues with mjml tags.
// Browsers only recognize explicit self-closing tags like <img /> and <br />, leading to rendering problems.
// This can be reverted once the issue with self-closing tags is resolved in grapesjs-mjml.
// See: https://github.com/GrapesJS/mjml/issues/149
const voidTypes = ['mj-image', 'mj-divider', 'mj-font', 'mj-spacer'];
voidTypes.forEach(function(component) {
editor.DomComponents.addType(component, {
model: {
defaults: {
void: false
},
toHTML() {
const tag = this.get('tagName');
const attr = this.getAttrToHTML();
const content = this.get('content');
let strAttr = '';
for (let prop in attr) {
const val = attr[prop];
const hasValue = typeof val !== 'undefined' && val !== '';
strAttr += hasValue ? ` ${prop}="${val}"` : '';
}
let html = `<${tag}${strAttr}>${content}</${tag}>`;
// Add the components after the closing tag
const componentsHtml = this.get('components')
.map(model => model.toHTML())
.join('');
return html + componentsHtml;
},
}
});
});
}
initEmailHtml() {
const components = contentService.getOriginalContentHtml().body.innerHTML;
if (!components) {
throw new Error('no components');
}
const styles = [
`${mauticBaseUrl}plugins/GrapesJsBuilderBundle/Assets/library/js/grapesjs-editor.css`
];
// Launch GrapesJS with body part
this.editor = grapesjs.init({
clearOnRender: true,
container: '.builder-panel',
components,
height: '100%',
canvas: {
styles,
},
storageManager: false,
assetManager: this.getAssetManagerConf(),
plugins: [grapesjsnewsletter, grapesjspostcss, grapesjsmautic, grapesjsckeditor, ...BuilderService.getPluginNames('email-html')],
pluginsOpts: {
grapesjsnewsletter: {
useCustomTheme: false,
},
grapesjsmautic: BuilderService.getMauticConf('email-html'),
[grapesjsckeditor]: BuilderService.getCkeConf('email:getBuilderTokens'),
...BuilderService.getPluginOptions('email-html'),
},
});
// add a Mautic custom block Button
this.editor.BlockManager.get('button').set({
content:
'<a href="#" target="_blank" style="display:inline-block;text-decoration:none;border-color:#4e5d9d;border-width: 10px 20px;border-style:solid; text-decoration: none; -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; background-color: #4e5d9d; display: inline-block;font-size: 16px; color: #ffffff; ">\n' +
'Button\n' +
'</a>',
});
return this.editor;
}
/**
* Return the names of dynamically added plugins
* @param context
* @returns string[]
*/
static getPluginNames(context) {
let plugins = [];
if (window.MauticGrapesJsPlugins) {
window.MauticGrapesJsPlugins.forEach((item) => {
if (item.name) {
if (!item.context || !Array.isArray(item.context) || item.context.length === 0) {
// if no context is given, the plugin is always added
plugins.push(item.name);
} else {
// check if the plugin should be added for the current editor context
item.context.forEach((pluginContext) => {
if (pluginContext === context) {
plugins.push(item.name);
}
})
}
}
});
}
return plugins;
}
/**
* Return the options of dynamically added plugins
* @param context
* @returns object[]
*/
static getPluginOptions(context) {
let pluginOptions = {};
if (window.MauticGrapesJsPlugins) {
window.MauticGrapesJsPlugins.forEach((item) => {
if (!item.context || !Array.isArray(item.context) || item.context.length === 0) {
// if no context is given, the plugin is always added
pluginOptions[item.name] = item.pluginOptions ?? {};
} else {
// check if the plugin should be added for the current editor context
item.context.forEach((pluginContext) => {
if (pluginContext === context) {
pluginOptions[item.name] = item.pluginOptions ?? {};
}
})
}
});
}
return pluginOptions;
}
/**
* Manage button loading indicator
*
* @param activate - true or false
*/
static setupButtonLoadingIndicator(activate) {
const builderButton = mQuery('.btn-builder');
const saveButton = mQuery('.btn-save');
const applyButton = mQuery('.btn-apply');
if (activate) {
Mautic.activateButtonLoadingIndicator(builderButton);
Mautic.activateButtonLoadingIndicator(saveButton);
Mautic.activateButtonLoadingIndicator(applyButton);
} else {
Mautic.removeButtonLoadingIndicator(builderButton);
Mautic.removeButtonLoadingIndicator(saveButton);
Mautic.removeButtonLoadingIndicator(applyButton);
}
}
/**
* Configure the Asset Manager for all modes
* @link https://grapesjs.com/docs/modules/Assets.html#configuration
*/
getAssetManagerConf() {
return {
assets: [],
noAssets: Mautic.translate('grapesjsbuilder.assetManager.noAssets'),
upload: this.assetService.getUploadPath(),
uploadName: 'files',
multiUpload: 1,
embedAsBase64: false,
openAssetsOnDrop: 1,
autoAdd: 1,
headers: { 'X-CSRF-Token': mauticAjaxCsrf }, // global variable
};
}
getEditor() {
return this.editor;
}
/**
* Move the blocks and categories in the sidebar
*/
moveBlocksPage() {
const blocks = this.editor.BlockManager.getAll();
blocks.map(block => {
// columns go into a new category, at the top
if(block.attributes.id.indexOf('column') !== -1) {
this.editor.BlockManager.get(block.attributes.id).set('category', {
label:"Sections",
order: -1
});
}
// 'Blocks' category goes after 'Basic'
if(block.attributes.category === 'Basic') {
this.editor.BlockManager.get(block.attributes.id).set('category', {
label:"Basic",
order: -1
});
}
});
}
removeSelectedElementsEmailMjml() {
// Remove the RAW block (it's just not usable)
const rawblock = this.editor.BlockManager.get('mj-raw');
if (rawblock !== null) {
this.editor.BlockManager.remove(rawblock);
}
}
}

View File

@@ -0,0 +1,129 @@
// import ContentService from '../../../../../../../grapesjs-preset-mautic/src/content.service';
import MjmlService from 'grapesjs-preset-mautic/dist/mjml/mjml.service';
import ContentService from 'grapesjs-preset-mautic/dist/content.service';
class CodeEditor {
editor;
opts;
codeEditor;
codePopup;
constructor(editor, opts = {}) {
this.editor = editor;
this.opts = opts;
this.codeEditor = this.buildCodeEditor();
this.codePopup = this.buildCodePopup();
}
// Build codeEditor (CodeMirror instance)
buildCodeEditor() {
const codeEditor = this.editor.CodeManager.getViewer('CodeMirror').clone();
codeEditor.set({
codeName: 'htmlmixed',
readOnly: false,
theme: 'hopscotch',
autoBeautify: true,
autoCloseTags: true,
autoCloseBrackets: true,
lineWrapping: true,
styleActiveLine: true,
smartIndent: true,
indentWithTabs: true,
});
return codeEditor;
}
// Build popup content, codeEditor area and buttons
buildCodePopup() {
const cfg = this.editor.getConfig();
const codePopup = document.createElement('div');
const btnEdit = document.createElement('button');
const btnCancel = document.createElement('button');
const textarea = document.createElement('textarea');
btnEdit.innerHTML = Mautic.translate('grapesjsbuilder.sourceEditBtnLabel');
btnEdit.className = `${cfg.stylePrefix}btn-prim ${cfg.stylePrefix}btn-code-edit`;
btnEdit.onclick = this.updateCode.bind(this);
btnCancel.innerHTML = Mautic.translate('grapesjsbuilder.sourceCancelBtnLabel');
btnCancel.className = `${cfg.stylePrefix}btn-prim ${cfg.stylePrefix}btn-code-cancel`;
btnCancel.onclick = this.cancelCode.bind(this);
codePopup.appendChild(textarea);
codePopup.appendChild(btnEdit);
codePopup.appendChild(btnCancel);
this.codeEditor.init(textarea);
return codePopup;
}
// Load content and show popup
showCodePopup(editor) {
this.updateEditorContents();
// this.codeEditor.editor.refresh();
// editor.Modal.setContent('');
editor.Modal.setContent(this.codePopup);
editor.Modal.setTitle(Mautic.translate('grapesjsbuilder.sourceEditModalTitle'));
editor.Modal.open();
editor.Modal.onceClose(() => editor.stopCommand('preset-mautic:code-edit'));
}
/**
* Update the main editors canvas content with the
* content from modals editor.
* @todo show validation results in UI
*/
updateCode() {
const code = this.codeEditor.editor.getValue();
// validate MJML code
if (ContentService.isMjmlMode(this.editor)) {
MjmlService.mjmlToHtml(code);
}
try {
// delete canvas and set new content
this.editor.DomComponents.getWrapper().set('content', '');
this.editor.setComponents(code.trim())
// Reinitialize the content after parsing MJML.
// This can be removed once the issue with self-closing tags is resolved in grapesjs-mjml.
// See: https://github.com/GrapesJS/mjml/issues/149
const parsedContent = MjmlService.getEditorMjmlContent(this.editor);
this.editor.setComponents(parsedContent);
this.editor.Modal.close();
} catch (e) {
window.alert(`${Mautic.translate('grapesjsbuilder.sourceSyntaxError')} \n${e.message}`);
}
}
// Close popup
cancelCode() {
this.editor.Modal.close();
}
/**
* Set the content to be edited in the popup editor
*/
updateEditorContents() {
// Check if MJML plugin is on
let content;
if (ContentService.isMjmlMode(this.editor)) {
content = MjmlService.getEditorMjmlContent(this.editor);
} else {
content = ContentService.getEditorHtmlContent(this.editor);
}
this.codeEditor.setContent(content);
}
}
export default CodeEditor;

View File

@@ -0,0 +1,35 @@
import CodeModeCommand from './codeMode.command';
export default class CodeModeButton {
editor;
/**
* Add close button with save for Mautic
*/
constructor(editor) {
if (!editor) {
throw new Error('no editor');
}
this.editor = editor;
}
addButton() {
this.editor.Panels.addButton('options', [
{
id: 'code-edit',
className: 'ri-edit-line',
attributes: {
title: Mautic.translate('grapesjsbuilder.sourceEditModalTitle'),
},
command: CodeModeCommand.name,
},
]);
}
addCommand() {
this.editor.Commands.add(CodeModeCommand.name, {
run: CodeModeCommand.launchCodeEditorModal,
stop: CodeModeCommand.stopCodeEditorModal,
});
}
}

View File

@@ -0,0 +1,35 @@
import CodeEditor from './codeEditor';
export default class CodeModeCommand {
/**
* The command to run on button click
*/
static name = 'preset-mautic:code-edit';
static codeEditor;
static launchCodeEditorModal(editor, sender, opts) {
if (!editor) {
throw new Error('no editor');
}
CodeModeCommand.codeEditor = new CodeEditor(editor, opts);
if (sender) {
sender.set('active', 0);
}
CodeModeCommand.codeEditor.showCodePopup(editor);
// Transform DC Component to token
editor.runCommand('preset-mautic:dynamic-content-components-to-tokens');
}
static stopCodeEditorModal(editor) {
if (!editor) {
throw new Error('no editor');
}
// Transform Token to Components
editor.runCommand('preset-mautic:update-dc-components-from-dc-store');
}
}

View File

@@ -0,0 +1,91 @@
// copied from: https://github.com/GrapesJS/grapesjs/issues/1855
export default class CompCopyPaste {
static storage_key = 'preset-mautic:grapesjs-clipboard';
editor;
// COPY PASTE COMPONENTS/STYLE BETWEEN PAGES
getStyles(components) {
// recurse down through components and store styles in temp attribute
components.forEach((component) => {
const recurse = (comp) => {
// if component has any styling
if (Object.keys(comp.getStyle()).length !== 0) comp.attributes.savedStyle = comp.getStyle();
if (comp.get('components').length) {
comp.get('components').forEach((child) => {
recurse(child);
});
}
};
recurse(component);
});
return components;
}
setStyles(component) {
// recurse down and re-apply style back to components
const recurse = (comp) => {
if ('savedStyle' in comp.attributes) {
comp.setStyle(comp.attributes.savedStyle);
delete comp.attributes.savedStyle;
}
if (comp.attributes.components.length) {
comp.attributes.components.forEach((child) => {
recurse(child);
});
}
};
recurse(component);
}
newCopy(selected) {
window.localStorage.setItem(this.storage_key, JSON.stringify(selected));
}
newPaste(selected) {
let components = JSON.parse(window.localStorage.getItem(this.storage_key));
if (components) {
if (selected && selected.attributes.type !== 'wrapper') {
const index = selected.index();
// Invert the order so last item gets added first and gets pushed down as others get added.
components.reverse();
const currentSelection = selected.collection;
components.forEach((comp) => {
if (currentSelection) {
const added = currentSelection.add(comp, { at: index + 1 });
editor.trigger('component:paste', added);
this.setStyles(added);
}
});
selected.emitUpdate();
} else {
components = editor.addComponents(components);
components.forEach((comp) => {
this.setStyles(comp);
});
}
}
}
constructor(editor) {
if (!editor) {
throw new Error('no editor');
}
this.editor = editor;
}
addCommand() {
this.editor.Commands.add('core:copy', (ed) => {
const selected = this.getStyles([...ed.getSelectedAll()]);
let filteredSelected = selected.filter((item) => item.attributes.copyable == true);
if (filteredSelected.length) {
this.newCopy(filteredSelected);
}
});
this.editor.Commands.add('core:paste', (ed) => {
const selected = ed.getSelected();
this.newPaste(selected);
});
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,375 @@
body,
html {
margin: 0;
height: 100%;
}
/* GrapesJS temporary fix #2490 */
.gjs-clm-tag-status,
.gjs-clm-tag-close {
}
.gjs-clm-tags-btn {
width: 24px;
}
/* Fix hidden scroll */
.gjs-pn-views-container {
height: auto;
padding: 0 0 0;
top: 40px;
bottom: 0;
background: #f0f0f0;
}
.gjs-btn-code-edit {
margin-top: 10px;
}
/* GrapesJS FileManager custom CSS */
.gjs-btn-code-cancel {
margin-left: 10px;
}
.gjs-am-file-uploader {
width: 100%;
float: none;
}
.gjs-am-assets-cont {
width: 100%;
float: none;
}
.gjs-am-assets {
height: 280px;
}
.gjs-am-asset {
width: 25%;
}
.gjs-am-file-uploader > form #gjs-am-uploadFile {
padding: 20px 10px;
}
.gjs-am-file-uploader #gjs-am-title {
padding: 20px 10px;
}
.gjs-am-preview {
background-size: contain;
}
.gjs-am-preview-cont {
width: 40%;
}
.gjs-am-meta {
width: 60%;
}
.gjs-pn-commands .gjs-pn-buttons {
justify-content: center;
}
/* GrapesJS Colors / Theme */
.gjs-clm-tags .gjs-sm-title,
.gjs-sm-sector .gjs-sm-title {
}
.gjs-block-category .gjs-title,
.gjs-category-title,
.gjs-clm-tags .gjs-sm-title,
.gjs-layer-title,
.gjs-sm-sector .gjs-sm-title {
font-weight: lighter;
background-color: rgba(0,0,0,.1);
letter-spacing: 1px;
padding: 9px 10px 9px 20px;
border-bottom: 1px solid rgba(0,0,0,.25);
text-align: left;
position: relative;
cursor: pointer;
}
.gjs-sm-properties {
font-size: .75rem;
padding: 10px 5px;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
box-sizing: border-box;
width: 100%;
}
.gjs-clm-tags .gjs-clm-tag {
border: 1px solid #707070;
box-shadow: none;
text-shadow: none;
}
.gjs-field {
box-shadow: none;
}
.gjs-btnt.gjs-pn-active,
.gjs-pn-btn.gjs-pn-active {
box-shadow: none;
}
.gjs-import-label,
.gjs-export-label {
margin-bottom: 10px;
font-size: 13px;
}
.gjs-mdl-dialog .gjs-btn-import {
margin-top: 10px;
}
.CodeMirror {
border-radius: 3px;
height: 450px;
font-family: sans-serif, monospace;
letter-spacing: 0.3px;
font-size: 12px;
}
/* GrapesJS Extra */
#gjs-pn-views-container.gjs-pn-panel {
padding: 39px 0 0;
}
#gjs-pn-views.gjs-pn-panel {
padding: 0;
border: none;
}
#gjs-pn-views .gjs-pn-btn {
margin: 0;
height: 40px;
padding: 10px;
width: 25%;
border-bottom: 2px solid rgba(0, 0, 0, 0.3);
}
#gjs-pn-views .gjs-pn-active {
border-radius: 0;
}
#gjs-pn-devices-c {
padding-left: 30px;
}
#gjs-pn-options {
padding-right: 30px;
}
.gjs-sm-composite .gjs-sm-properties {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
}
#gjs-sm-border-top-left-radius,
#gjs-sm-border-top-right-radius,
#gjs-sm-border-bottom-left-radius,
#gjs-sm-border-bottom-right-radius,
#gjs-sm-margin-top,
#gjs-sm-margin-bottom,
#gjs-sm-margin-right,
#gjs-sm-margin-left,
#gjs-sm-padding-top,
#gjs-sm-padding-bottom,
#gjs-sm-padding-right,
#gjs-sm-padding-left {
}
#gjs-sm-border-width,
#gjs-sm-border-style,
#gjs-sm-border-color {
flex: 999 1 80px;
}
#gjs-sm-margin-left,
#gjs-sm-padding-left {
order: 2;
}
#gjs-sm-margin-right,
#gjs-sm-padding-right {
order: 3;
}
#gjs-sm-margin-bottom,
#gjs-sm-padding-bottom {
order: 4;
}
.gjs-field-radio {
width: 100%;
}
.gjs-field-radio #gjs-sm-input-holder {
display: flex;
}
.gjs-radio-item {
flex: 1 0 auto;
text-align: center;
}
.gjs-sm-sector .gjs-sm-property.gjs-sm-list {
width: 50%;
}
.gjs-mdl-content {
border-top: none;
}
.gjs-sm-sector .gjs-sm-property .gjs-sm-layer.gjs-sm-active {
background-color: rgba(255, 255, 255, 0.09);
}
.gjs-f-divider::before {
content: 'D';
}
.gjs-mdl-dialog-sm {
width: 300px;
}
.gjs-mdl-dialog form .gjs-sm-property {
font-size: 12px;
margin-bottom: 15px;
}
.gjs-mdl-dialog form .gjs-sm-label {
margin-bottom: 5px;
}
#gjs-clm-status-c {
display: none;
}
.anim-spin {
animation: 0.5s linear 0s normal none infinite running spin;
}
.form-status {
float: right;
font-size: 14px;
}
.text-danger {
color: #f92929;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.is-not-allowed {
cursor: not-allowed;
}
.link-is-disabled {
pointer-events: none;
text-decoration: none;
display: inline-block;
opacity: 0.5;
}
/* overrides for the icons now that they are SVGs */
.gjs-pn-panel .gjs-block__media svg {
max-height: 48px;
}
/* Newsletter preset theme */
.gjs-one-bg {
background-color: #373d49;
}
.gjs-one-color {
color: #373d49;
}
.gjs-one-color-h:hover {
color: #373d49;
}
.gjs-two-bg{
color: #dae5e6;
}
.gjs-two-color {
color: #dae5e6;
}
.gjs-two-color-h:hover {
color: #dae5e6;
}
.gjs-three-bg {
background-color: #4c9790;
}
.gjs-three-color {
color: #4c9790;
}
.gjs-three-color-h:hover {
color: #4c9790;
}
.gjs-four-bg {
background-color: #35d7bb;
}
.gjs-four-color {
color: #35d7bb;
}
.gjs-four-color-h:hover {
color: #35d7bb;
}
/* revert colors for nested panels */
.gjs-mdl-dialog .panel {
color: var(--gjs-main-color);
}
.gjs-assets-spinner {
width: 100%;
text-align: center;
font-size: 26px;
}
.anim-spin {
animation: 0.5s linear 0s normal none infinite running spin; }
@keyframes spin {
0% {
transform: rotate(0deg); }
100% {
transform: rotate(360deg); } }
/* CKEditor styles */
.cke-modal .ck-editor .ck-content {
min-height: 400px;
background: var(--background);
color: var(--text-primary);
}
.cke-modal .gjs-btn-prim {
margin: 10px 5px 5px 0;
}

View File

@@ -0,0 +1,5 @@
html,
body,
[data-gjs-type='wrapper'] {
height: auto !important;
}

View File

@@ -0,0 +1,129 @@
export default (editor, opts = {}) => {
let ckEditorInstance = null;
const SIMPLE_EDITING_TYPES = ['mj-button', 'link'];
editor.on('rte:enable', (view, gjsRte) => {
if (!isSimpleEditingEl(view.el)) {
openCKEditorModal(gjsRte.el, view);
editor.RichTextEditor.hideToolbar();
}
});
function isSimpleEditingEl(el) {
return el && el.getAttribute && SIMPLE_EDITING_TYPES.includes(el.getAttribute('data-gjs-type'));
}
function openCKEditorModal(el, view) {
const ckEditorElementId = `ckeditor-${Date.now()}`;
const modal = editor.Modal;
modal.onceOpen(() => initCKEditor(ckEditorElementId));
modal.onceClose(() => destroyCKEditor());
modal.config.backdrop = false;
modal.open({
title: 'Edit',
content: `
<div id="${ckEditorElementId}">${el.innerHTML}</div>
<button type="button" class="gjs-btn-prim" id="gjs-cke-save-btn">Save</button>
<button type="button" class="gjs-btn-prim" id="gjs-cke-close-btn">Cancel</button>
`,
attributes: {
class: 'cke-modal'
}
});
const { backgroundColor, color } = getRealColors(el);
setEditorStyle(color, backgroundColor);
document.getElementById('gjs-cke-save-btn').onclick = () => saveContent(view, modal);
document.getElementById('gjs-cke-close-btn').onclick = () => modal.close();
}
function initCKEditor(elementId) {
if (typeof ClassicEditor === 'undefined') {
throw new Error('CKEDITOR instance not found');
}
ClassicEditor.create(document.getElementById(elementId), opts)
.then(instance => {
ckEditorInstance = instance;
})
.catch(error => {
console.error('Error initializing CKEditor:', error);
});
}
function destroyCKEditor() {
if (ckEditorInstance) {
ckEditorInstance.destroy()
.catch(error => {
console.error('Error destroying CKEditor instance:', error);
});
}
}
function saveContent(view, modal) {
if (ckEditorInstance) {
const content = ckEditorInstance.getData();
const selectedElement = view.model;
const currentContent = selectedElement.get('content');
if (currentContent !== content) {
// Clear existing components to avoid conflicts
selectedElement.components('');
// Set the new content
selectedElement.set('content', content);
}
}
modal.close();
}
function setEditorStyle(color, backgroundColor) {
const STYLE_ID = 'gjs-ckeditor-styles';
let styleElement = document.getElementById(STYLE_ID);
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = STYLE_ID;
document.head.appendChild(styleElement);
}
styleElement.innerHTML = `
.cke-modal .ck-editor .ck-content {
background: ${backgroundColor};
color: ${color};
}
`;
}
function getRealColors(elem, maxDepth = 100) {
const transparent = ['rgba(0, 0, 0, 0)', 'transparent'];
const defaults = { backgroundColor: 'rgba(0, 0, 0, 0)', color: 'rgb(0, 0, 0)' };
function getColors(el, depth) {
if (!el || depth <= 0) return defaults;
try {
const style = getComputedStyle(el);
const bg = style.backgroundColor;
const color = style.color;
const result = {};
if (!transparent.includes(bg)) result.backgroundColor = bg;
if (color && !transparent.includes(color)) result.color = color;
if (result.backgroundColor && result.color) return result;
const parentColors = getColors(el.parentElement, depth - 1);
return {
backgroundColor: result.backgroundColor || parentColors.backgroundColor,
color: result.color || parentColors.color
};
} catch (error) {
console.warn('Error computing colors:', error);
return defaults;
}
}
return getColors(elem, maxDepth);
}
};

View File

@@ -0,0 +1,182 @@
import MjmlService from "grapesjs-preset-mautic/dist/mjml/mjml.service";
import ContentService from 'grapesjs-preset-mautic/dist/content.service';
export default class StorageService {
constructor(editor, mode) {
this.editor = editor;
this.mode = mode;
this.maxStorageItems = 10;
this.init();
}
init() {
this.storageKey = 'gjs-storage';
this.restoreMessage = null;
const stackItemId = this.getStackItemId();
const storageItem = this.getStorageItemById(stackItemId);
const editorContent = this.getEditorContent();
if (storageItem && editorContent !== storageItem.content) {
this.displayRestoreMessage(storageItem);
}
this.editor.on("update", () => this.handleUpdate());
this.addFormSubmitListeners();
}
displayRestoreMessage(storedContent) {
const buttonContainer = document.createElement('div');
buttonContainer.className = 'alert-growl-buttons';
const restoreButton = document.createElement('button');
restoreButton.innerHTML = '<i class="ri-arrow-go-back-line"></i> ' + Mautic.translate('mautic.core.builder.storage.restore.button')
restoreButton.className = 'btn btn-primary btn-sm ml-md';
const dismissButton = document.createElement('button');
dismissButton.innerHTML = Mautic.translate('mautic.core.builder.storage.dismiss.button')
dismissButton.className = 'btn btn-ghost btn-sm';
buttonContainer.append(restoreButton, dismissButton);
const formattedDateTime = this.formatDateTime(storedContent.date);
const message = Mautic.translate('mautic.core.builder.storage.restore.message', {
date: formattedDateTime
});
const flashMessage = Mautic.addInfoFlashMessage(message);
flashMessage.append(buttonContainer);
const closeButton = flashMessage.querySelector('button.close')
this.addMessageEventListeners(restoreButton, dismissButton, closeButton);
Mautic.setFlashes(flashMessage, false);
this.restoreMessage = flashMessage;
}
dismissRestoreMessage() {
if (this.restoreMessage instanceof Element) {
this.restoreMessage.remove();
}
this.restoreMessage = null;
}
addMessageEventListeners(restoreButton, dismissButtom, closeButton) {
restoreButton.addEventListener('click', (event) => {
this.load();
this.dismissRestoreMessage();
event.preventDefault();
});
dismissButtom.addEventListener('click', (event) => {
this.handleUpdate();
this.dismissRestoreMessage();
this.removeStorageItemById(this.getStackItemId());
event.preventDefault();
});
closeButton.addEventListener('click', () => {
this.handleUpdate();
});
this.editor.on('hide', () => this.dismissRestoreMessage());
}
addFormSubmitListeners() {
mQuery(this.getForm()).on('submit:success', (e, requestUrl, response) => {
const lastRequestUrlPart = requestUrl.split('/').pop();
const lastResponseUrlPart = response.route.split('/').pop();
// Check if the form was submitted for a new entity and the response contains the entity id
// The success response code alone does not guarantee that the form was saved,
// so we need to validate the URL changes and the presence of the entity id
if (lastRequestUrlPart === 'new' && !isNaN(lastResponseUrlPart)){
// Remove the local storage item for the newly created entity after successful form submission
this.removeStorageItemById(`gjs-${this.mode}-${Mautic.builderTheme}-new`);
}
});
}
handleUpdate() {
// update the storage content only when the restore prompt is not available
if (!this.restoreMessage) {
const editorContent = this.getEditorContent();
const dateTime = new Date().toISOString();
const stackItemId = this.getStackItemId();
const contentWithDateTime = { id: stackItemId, content: editorContent, date: dateTime };
this.saveStorageItem(contentWithDateTime);
}
}
load() {
const stackItemId = this.getStackItemId();
const storageItem = this.getStorageItemById(stackItemId);
if (storageItem) {
this.editor.setComponents(storageItem.content);
}
}
getEditorContent() {
let content;
if (ContentService.isMjmlMode(this.editor)) {
content = MjmlService.getEditorMjmlContent(this.editor);
} else {
content = ContentService.getEditorHtmlContent(this.editor);
}
return content;
}
getStackItemId() {
const entityId = this.getFormEntityId(this.mode === 'page' ? 'page' : 'emailform')
return `gjs-${this.mode}-${Mautic.builderTheme}-${entityId}`;
}
saveStorageItem(item) {
const stack = JSON.parse(localStorage.getItem(this.storageKey)) || [];
const index = stack.findIndex(existingItem => existingItem.id === item.id);
if (index !== -1) {
// If the item already exists, update it
stack[index] = item;
} else {
// If the item doesn't exist, push it to the stack
if (stack.length >= this.maxStorageItems) {
// Ensure that the stack does not exceed the maximum allowed number of items
// to prevent web storage from exceeding its 10MiB per domain limit
stack.pop(); // Remove the oldest item
}
stack.push(item);
}
stack.sort((a, b) => new Date(b.date) - new Date(a.date));
localStorage.setItem(this.storageKey, JSON.stringify(stack));
}
getStorageItemById(id) {
const stack = JSON.parse(localStorage.getItem(this.storageKey)) || [];
return stack.find(item => item.id === id);
}
removeStorageItemById(id) {
const stack = JSON.parse(localStorage.getItem(this.storageKey)) || [];
const index = stack.findIndex(item => item.id === id);
if (index !== -1) {
stack.splice(index, 1);
localStorage.setItem(this.storageKey, JSON.stringify(stack));
}
}
formatDateTime(dateTime) {
return new Date(dateTime).toISOString().slice(0, 16).replace('T', ' ');
}
getFormEntityId(name) {
const form = document.querySelector(`form[name="${name}"]`);
const actionUrl = form.getAttribute('action');
const urlParts = actionUrl.split('/');
const lastPart = urlParts.pop();
if (isNaN(lastPart)) {
return 'new';
} else {
return lastPart;
}
}
getForm() {
return document.querySelector(this.mode === 'page' ? 'form[name="page"]' : 'form[name="emailform"]')
}
}