Skip to content

Commit b3d3bf5

Browse files
committed
OXDEV-10119 Add example for WYSIWYG Summernote extending
1 parent a4f0fc3 commit b3d3bf5

6 files changed

Lines changed: 513 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](http://keepachangelog.com/)
55
and this project adheres to [Semantic Versioning](http://semver.org/).
66

7+
## [v2.1.0] - Unreleased
8+
9+
### Added
10+
- `ddoe/wysiwyg-editor-module` dependency
11+
- Example of extending another module's Twig blocks - customizing the Summernote WYSIWYG editor with additional plugins and options
12+
713
## [v2.0.0] - 2025-11-27
814

915
### Added
@@ -31,4 +37,5 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
3137
### Added
3238
- The module is extracted from the [Module template](https://github.com/OXID-eSales/module-template) repository.
3339

40+
[v2.1.0]: https://github.com/OXID-eSales/examples-module/compare/v2.0.0...b-7.5.x
3441
[v2.0.0]: https://github.com/OXID-eSales/examples-module/compare/v1.0.0...v2.0.0

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ The repository contains examples of following cases and more:
122122
* [extending of oxid theme templates or blocks](views/twig/extensions/themes)
123123
* extending a shop admin template block (`admin_user_main_form` - only an extension of a block, without functionality)
124124
* extending a shop template block (`start_newest_articles`)
125+
* [extending another module's template blocks](views/twig/extensions/modules/ddoewysiwyg/ddoewysiwyg.html.twig)
126+
* extending the `ddoe/wysiwyg-editor-module` (Summernote editor) to add custom plugins and options
127+
* `ddoe_wysiwyg_plugins` block - load additional Summernote plugin scripts via `{{ script() }}`
128+
* `ddoe_wysiwyg_summernote_options` block - customize editor options (toolbar, fonts, etc.) via `Object.assign()`
125129

126130
* Using the translations for your module specific phrases
127131
* [in admin](views/admin_twig)
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
/* https://github.com/DiemenDesign/summernote-cleaner */
2+
/* Version: 1.1.0 */
3+
(function (factory) {
4+
if (typeof define === 'function' && define.amd) {
5+
define(['jquery'], factory);
6+
} else if (typeof module === 'object' && module.exports) {
7+
module.exports = factory(require('jquery'));
8+
} else {
9+
factory(window.jQuery);
10+
}
11+
}
12+
(function ($) {
13+
$.extend(true, $.summernote.lang, {
14+
'en-US': {
15+
cleaner: {
16+
tooltip: 'Cleaner',
17+
not: 'Text has been cleaned!',
18+
limitText: 'Text',
19+
limitHTML: 'HTML'
20+
}
21+
},
22+
'de-DE': {
23+
cleaner: {
24+
tooltip: 'Bereinigen',
25+
not: 'Inhalt wurde bereinigt!',
26+
limitText: 'Text',
27+
limitHTML: 'HTML'
28+
}
29+
},
30+
});
31+
$.extend($.summernote.options, {
32+
cleaner: {
33+
action: 'both', // both|button|paste 'button' only cleans via toolbar button, 'paste' only clean when pasting content, both does both options.
34+
icon: '<i class="note-icon"><svg xmlns="http://www.w3.org/2000/svg" id="libre-paintbrush" viewBox="0 0 14 14" width="14" height="14"><path d="m 11.821425,1 q 0.46875,0 0.82031,0.311384 0.35157,0.311384 0.35157,0.780134 0,0.421875 -0.30134,1.01116 -2.22322,4.212054 -3.11384,5.035715 -0.64956,0.609375 -1.45982,0.609375 -0.84375,0 -1.44978,-0.61942 -0.60603,-0.61942 -0.60603,-1.469866 0,-0.857143 0.61608,-1.419643 l 4.27232,-3.877232 Q 11.345985,1 11.821425,1 z m -6.08705,6.924107 q 0.26116,0.508928 0.71317,0.870536 0.45201,0.361607 1.00781,0.508928 l 0.007,0.475447 q 0.0268,1.426339 -0.86719,2.32366 Q 5.700895,13 4.261155,13 q -0.82366,0 -1.45982,-0.311384 -0.63616,-0.311384 -1.0212,-0.853795 -0.38505,-0.54241 -0.57924,-1.225446 -0.1942,-0.683036 -0.1942,-1.473214 0.0469,0.03348 0.27455,0.200893 0.22768,0.16741 0.41518,0.29799 0.1875,0.130581 0.39509,0.24442 0.20759,0.113839 0.30804,0.113839 0.27455,0 0.3683,-0.247767 0.16741,-0.441965 0.38505,-0.753349 0.21763,-0.311383 0.4654,-0.508928 0.24776,-0.197545 0.58928,-0.31808 0.34152,-0.120536 0.68974,-0.170759 0.34821,-0.05022 0.83705,-0.07031 z"/></svg></i>',
35+
keepHtml: true,
36+
keepTagContents: ['span'], //Remove tags and keep the contents
37+
badTags: ['applet', 'col', 'colgroup', 'embed', 'noframes', 'noscript', 'script', 'style', 'title', 'meta', 'link', 'head'], //Remove full tags with contents
38+
badAttributes: ['bgcolor', 'border', 'height', 'cellpadding', 'cellspacing', 'lang', 'start', 'style', 'valign', 'width', 'data-(.*?)'], //Remove attributes from remaining tags, NB. 'data-(.*?)' would fail when cleaning with jQuery
39+
limitChars: 0, // 0|# 0 disables option
40+
limitDisplay: 'both', // none|text|html|both
41+
limitStop: false, // true/false
42+
limitType: 'text', // text|html
43+
notTimeOut: 850, //time before status message is hidden in miliseconds
44+
keepImages: true,
45+
imagePlaceholder: 'https://via.placeholder.com/200'
46+
}
47+
});
48+
$.extend($.summernote.plugins, {
49+
'cleaner': function (context) {
50+
var ui = $.summernote.ui,
51+
$note = context.layoutInfo.note,
52+
$editor = context.layoutInfo.editor,
53+
options = context.options,
54+
lang = options.langInfo;
55+
56+
if (! options.cleaner.hasOwnProperty('limitType')) {
57+
options.cleaner.limitType = 'text';
58+
}
59+
60+
if (options.cleaner.action === 'both' || options.cleaner.action === 'button') {
61+
context.memo('button.cleaner', function () {
62+
var button = ui.button({
63+
contents: options.cleaner.icon,
64+
container: options.container,
65+
tooltip: lang.cleaner.tooltip,
66+
placement: options.placement,
67+
click: function () {
68+
if ($note.summernote('createRange').toString()) {
69+
$note.summernote('pasteHTML', $note.summernote('createRange').toString());
70+
} else {
71+
$note.summernote('code', cleanPaste($note.summernote('code'), options.cleaner.badTags, options.cleaner.keepTagContents, options.cleaner.badAttributes, options.cleaner.keepImages, options.cleaner.imagePlaceholder, true));
72+
}
73+
showCleanedAlert();
74+
}
75+
});
76+
return button.render();
77+
});
78+
}
79+
this.events = {
80+
'summernote.init': function () {
81+
updateCleanerStatus();
82+
},
83+
'summernote.keydown': function (we, event) {
84+
if (options.cleaner.limitChars !== 0 && options.cleaner.limitStop === true) {
85+
var testLength = (options.cleaner.limitType === 'html') ? $editor.find('.note-editable').html().length : $editor.find(".note-editable").text().replace(/(<([^>]+)>)/ig, "").replace(/( )/, " ").length;
86+
if (testLength >= options.cleaner.limitChars) {
87+
var key = event.keyCode;
88+
allowed_keys = [8, 37, 38, 39, 40, 46];
89+
if ($.inArray(key, allowed_keys) !== -1) {
90+
return true;
91+
} else {
92+
event.preventDefault();
93+
event.stopPropagation();
94+
}
95+
}
96+
}
97+
},
98+
'summernote.keyup': function (we, event) {
99+
updateCleanerStatus();
100+
},
101+
'summernote.paste': function (we, event) {
102+
if (options.cleaner.action === 'both' || options.cleaner.action === 'paste') {
103+
event.preventDefault();
104+
105+
// delete selected text when pasting and paste it where the deleted text was
106+
if (document.getSelection().toString().length > 0) {
107+
rng = $.summernote.range;
108+
r = rng.createFromSelection();
109+
r = r.deleteContents();
110+
$note.summernote('editor.setLastRange', r.select());
111+
}
112+
113+
var ua = window.navigator.userAgent;
114+
var msie = ua.indexOf("MSIE ");
115+
msie = msie > 0 || !!navigator.userAgent.match(/Trident.*rv:11\./);
116+
var ffox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
117+
var text; var isHtmlData = false;
118+
if (msie)
119+
text = window.clipboardData.getData("Text");
120+
else {
121+
var dataType = 'text/plain';
122+
// only get the html data if its available else use plain text
123+
if (options.cleaner.keepHtml && event.originalEvent.clipboardData.types.indexOf('text/html') > -1) {
124+
dataType = 'text/html';
125+
isHtmlData = true;
126+
}
127+
text = event.originalEvent.clipboardData.getData(dataType);
128+
}
129+
if (text) {
130+
// clean the text first to prevent issues where code view wasn't updating correctly
131+
var cleanedContent = cleanPaste(text, options.cleaner.badTags, options.cleaner.keepTagContents, options.cleaner.badAttributes, options.cleaner.keepImages, options.cleaner.imagePlaceholder, isHtmlData);
132+
if (msie || ffox) {
133+
setTimeout(function () {
134+
$note.summernote('pasteHTML', cleanedContent);
135+
}, 1);
136+
} else {
137+
$note.summernote('pasteHTML', cleanedContent);
138+
}
139+
140+
showCleanedAlert();
141+
}
142+
} else {
143+
updateCleanerStatus();
144+
}
145+
}
146+
}
147+
148+
var updateCleanerStatus = function() {
149+
if (options.cleaner.limitChars !== 0 || options.cleaner.limitDisplay !== 'none') {
150+
var $cleanerStatus = $editor.find('.cleanerLimit');
151+
if ($cleanerStatus.length === 0) {
152+
$editor.find('.note-status-output').html('<small class="cleanerLimit pull-right">&nbsp;</small>');
153+
$cleanerStatus = $editor.find('.cleanerLimit');
154+
}
155+
156+
var textLength = $editor.find(".note-editable").text().replace(/(<([^>]+)>)/ig, "").replace(/( )/, " ").length;
157+
var codeLength = $editor.find('.note-editable').html().length;
158+
var testLength = (options.cleaner.limitType === 'html') ? codeLength : textLength;
159+
var lengthStatus = '';
160+
161+
if (testLength > options.cleaner.limitChars && options.cleaner.limitChars > 0) {
162+
$cleanerStatus.addClass('text-danger');
163+
} else {
164+
$cleanerStatus.removeClass('text-danger');
165+
}
166+
167+
if (options.cleaner.limitDisplay === 'text' || options.cleaner.limitDisplay === 'both') {
168+
lengthStatus += lang.cleaner.limitText + ': ' + textLength;
169+
if (options.cleaner.limitType === 'text') {
170+
lengthStatus += ' / ' + options.cleaner.limitChars;
171+
}
172+
}
173+
if (options.cleaner.limitDisplay === 'both') {
174+
lengthStatus += ' | ';
175+
}
176+
if (options.cleaner.limitDisplay === 'html' || options.cleaner.limitDisplay === 'both') {
177+
lengthStatus += lang.cleaner.limitHTML + ': ' + codeLength;
178+
if (options.cleaner.limitType === 'html') {
179+
lengthStatus += ' / ' + options.cleaner.limitChars;
180+
}
181+
}
182+
183+
lengthStatus += '&nbsp;';
184+
185+
$cleanerStatus.html(lengthStatus);
186+
}
187+
}
188+
189+
var showCleanedAlert = function() {
190+
191+
if ($editor.find('.note-status-output').length > 0) {
192+
$editor.find('.note-status-output').html(lang.cleaner.not);
193+
// now set a timeout to clear out the message
194+
setTimeout(function () {
195+
if ($editor.find('.note-status-output').html() === lang.cleaner.not) {
196+
// lets fade out the text, then clear it and show the control ready for next time
197+
$editor.find('.note-status-output').fadeOut(function () {
198+
$(this).html("");
199+
$(this).fadeIn();
200+
updateCleanerStatus();
201+
});
202+
}
203+
}, options.cleaner.notTimeOut)
204+
}
205+
206+
}
207+
208+
var cleanPaste = function (input, badTags, keepTagContents, badAttributes, keepImages, imagePlaceholder, isHtmlData) {
209+
if (isHtmlData) {
210+
return cleanHtmlPaste(input, badTags, keepTagContents, badAttributes, keepImages, imagePlaceholder);
211+
} else {
212+
return cleanTextPaste(input);
213+
}
214+
};
215+
216+
var cleanTextPaste = function (input) {
217+
var newLines = /(\r\n|\r|\n)/g;
218+
// lets only replace < and > as these are the culprit for HTML tag recognition
219+
let inputEscapedHtml = input.replace('<', '&#60').replace('>', '&#62');
220+
var parsedInput = inputEscapedHtml.split(newLines);
221+
if (parsedInput.length === 1) { return inputEscapedHtml; }
222+
var output = "";
223+
// for larger blocks of text (such as multiple paragraphs) match summernote markup
224+
for (let contentIndex = 0; contentIndex < parsedInput.length; contentIndex++) {
225+
const element = parsedInput[contentIndex];
226+
if (!newLines.test(element)) {
227+
var line = element === '' ? '<br>' : element;
228+
output += '<p>' + line + '</p>'
229+
}
230+
}
231+
return output;
232+
}
233+
234+
var cleanHtmlPaste = function (input, badTags, keepTagContents, badAttributes, keepImages, imagePlaceholder) {
235+
if (typeof (window.jQuery) === 'function') {
236+
return cleanHtmlPasteWithjQuery(input, badTags, keepTagContents, badAttributes, keepImages, imagePlaceholder)
237+
} else {
238+
return cleanHtmlPasteWithRegExp(input, badTags, keepTagContents, badAttributes, keepImages, imagePlaceholder)
239+
}
240+
}
241+
242+
var cleanHtmlPasteWithRegExp = function (input, badTags, keepTagContents, badAttributes, keepImages, imagePlaceholder) {
243+
let i;
244+
var stringStripper = /( class=(")?Mso[a-zA-Z]+(")?)/gmi;
245+
// remove MS office class crud
246+
var output = input.replace(stringStripper, '');
247+
var commentStripper = new RegExp('<!--(.*?)-->', 'gmi');
248+
output = output.replace(commentStripper, '');
249+
// remove MS office comment if else crud
250+
var commentIfStripper = new RegExp('<![^>\v]*>', 'gmi');
251+
output = output.replace(commentIfStripper, '');
252+
var tagStripper = new RegExp('<(/)*(\\?xml:|st1:|o:|v:)[^>\v]*>', 'gmi');
253+
if (!keepImages) {
254+
output = output.replace(/ src="(.*?)"/gmi, ' src="' + imagePlaceholder + '"');
255+
}
256+
output = output.replace(/ name="(.*?)"/gmi, ' data-title="$1" alt="$1"');
257+
// remove MS office tag crud
258+
output = output.replace(tagStripper, '');
259+
for (i = 0; i < badTags.length; i++) {
260+
const badTag = badTags[i];
261+
// remove the tag and its contents
262+
tagStripper = new RegExp('<' + badTag + '(.|\r|\n)*</' + badTag + '[^>\v]*>', 'gmi');
263+
output = output.replace(tagStripper, '');
264+
// remove tags with no ending tag or rogue ending tags
265+
var singletonTagStripper = new RegExp('</?' + badTag + '[^>\v]*>', 'gmi');
266+
output = output.replace(singletonTagStripper, '');
267+
}
268+
for (i = 0; i < keepTagContents.length; i++) {
269+
// remove tags only
270+
tagStripper = new RegExp('</?' + keepTagContents[i] + '[^>\v]*>', 'gmi');
271+
output = output.replace(tagStripper, ' ');
272+
}
273+
for (i = 0; i < badAttributes.length; i++) {
274+
const badAttribute = badAttributes[i];
275+
// for attribute matching ensure we match a new line or some kind of space to prevents partial matching for attributes (e.g. color would modify bgcolor tag to be just bg)
276+
var attributeWithSpeechMarksStripper = new RegExp('(\s|\r\n|\r|\n| )' + badAttribute + '="[^"\v]*"', 'gmi');
277+
output = output.replace(attributeWithSpeechMarksStripper, '');
278+
var attributeWithApostropheStripper = new RegExp('(\s|\r\n|\r|\n| )' + badAttribute + "='[^'\v]*'", 'gmi');
279+
output = output.replace(attributeWithApostropheStripper, '');
280+
}
281+
output = output.replace(/ align="(.*?)"/gi, ' class="text-$1"');
282+
output = output.replace(/ class="western"/gi, '');
283+
output = output.replace(/ class=""/gi, '');
284+
output = output.replace(/<b>(.*?)<\/b>/gi, '<strong>$1</strong>');
285+
output = output.replace(/<i>(.*?)<\/i>/gi, '<em>$1</em>');
286+
output = output.replace(/\s{2,}/g, ' ').trim();
287+
return output;
288+
}
289+
290+
// Doing similar thing as RegExp one but keeping the classes and <b> <i> as is
291+
var cleanHtmlPasteWithjQuery = function (input, badTags, keepTagContents, badAttributes, keepImages, imagePlaceholder) {
292+
let i;
293+
var newLines = /(\r\n|\r|\n)/g;
294+
input = input.replace(newLines, ' ');
295+
296+
sanidom = $('<div></div>').html(input)
297+
298+
if (!keepImages) {
299+
sanidom.find("img").attr('src', imagePlaceholder)
300+
}
301+
// regEx version includes this conversion: output = output.replace(/ name="(.*?)"/gmi, ' data-title="$1" alt="$1"');
302+
sanidom.find('[name]').each(function(i, e) {
303+
var $e = $(e);
304+
var attV = $e.attr('name');
305+
$e.attr({'data-title': attV, alt: attV});
306+
});
307+
308+
for (i = 0; i < badTags.length; i++) {
309+
sanidom.find(badTags[i]).remove()
310+
}
311+
312+
sanidom.find(':empty').remove();
313+
314+
for (i = 0; i < keepTagContents.length; i++) {
315+
sanidom.find(keepTagContents[i]).replaceWith(function() {
316+
return cleanReplacement(keepTagContents[i], $(this).html());
317+
});
318+
}
319+
320+
for (i = 0; i < badAttributes.length; i++) {
321+
sanidom.find("[" + badAttributes[i] + "]").removeAttr(badAttributes[i])
322+
}
323+
324+
sanidom.find('[align]').each(function () {
325+
me = $(this)
326+
me.addClass("text-" + me.attr('align'))
327+
});
328+
329+
// output = output.replace(/<b>(.*?)<\/b>/gi, '<strong>$1</strong>');
330+
sanidom.find('b').replaceWith(function() {
331+
return $('<strong>').append($(this).html());
332+
});
333+
334+
// output = output.replace(/<i>(.*?)<\/i>/gi, '<em>$1</em>');
335+
sanidom.find('i').replaceWith(function() {
336+
return $('<em>').append($(this).html());
337+
});
338+
339+
sanidom.contents().filter(function () {
340+
return ((this.nodeType === Node.TEXT_NODE && !/\S/.test(this.nodeValue)) || this.nodeType === Node.COMMENT_NODE)
341+
}).remove()
342+
343+
return sanidom.html().replace(newLines, '');
344+
}
345+
346+
var cleanReplacement = function(targetTag, replacement) {
347+
var $test = $('<div />').append(replacement);
348+
if ($test.find(targetTag).length > 0) {
349+
$test.find(targetTag).replaceWith(function() {
350+
return cleanReplacement(targetTag, $(this).html())
351+
});
352+
return $test.html();
353+
}
354+
return replacement;
355+
}
356+
357+
}
358+
});
359+
}));

0 commit comments

Comments
 (0)