odoo.define('web_editor.wysiwyg.plugin.text', function (require) {
'use strict';
var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract');
var registry = require('web_editor.wysiwyg.plugin.registry');
var dom = $.summernote.dom;
dom.isAnchor = function (node) {
return (node.tagName === 'A' || node.tagName === 'BUTTON' || $(node).hasClass('btn')) &&
!$(node).hasClass('fa') && !$(node).hasClass('o_image');
};
var TextPlugin = AbstractPlugin.extend({
events: {
'summernote.paste': '_onPaste',
},
// See: https://dvcs.w3.org/hg/editing/raw-file/tip/editing.html#removeformat-candidate
formatTags: [
'abbr',
'acronym',
'b',
'bdi',
'bdo',
'big',
'blink',
'cite',
'code',
'dfn',
'em',
'font',
'i',
'ins',
'kbd',
'mark',
'nobr',
'q',
's',
'samp',
'small',
'span',
'strike',
'strong',
'sub',
'sup',
'tt',
'u',
'var',
],
tab: '\u00A0\u00A0\u00A0\u00A0',
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Insert a Horizontal Rule element (hr).
*/
insertHR: function () {
var self = this;
var hr = this.document.createElement('hr');
this.context.invoke('HelperPlugin.insertBlockNode', hr);
var point = this.context.invoke('HelperPlugin.makePoint', hr, 0);
point = dom.nextPointUntil(point, function (pt) {
return pt.node !== hr && !self.options.isUnbreakableNode(pt.node);
}) || point;
var range = $.summernote.range.create(point.node, point.offset);
range.select();
},
/**
* Insert a TAB (4 non-breakable spaces).
*/
insertTab: function () {
this.context.invoke('HelperPlugin.insertTextInline', this.tab);
},
/**
* Paste nodes or their text content into the editor.
*
* @param {Node[]} nodes
* @param {Boolean} textOnly true to allow only dropping plain text
*/
pasteNodes: function (nodes, textOnly) {
if (!nodes.length) {
return;
}
nodes = textOnly ? this.document.createTextNode($(nodes).text()) : nodes;
nodes = textOnly ? nodes : this._mergeAdjacentULs(nodes);
var point = this._getPastePoint();
// Prevent pasting HTML within a link:
point = textOnly ? point : dom.nextPointUntil(point, this._isPointInAnchor.bind(this));
this._insertNodesAt(nodes, point);
var start = nodes[nodes.length - 1];
this.context.invoke('editor.setRange', start, dom.nodeLength(start)).normalize().select();
},
/**
* Prepare clipboard data for safe pasting into the editor.
*
* @see clipboardWhitelist
* @see clipboardBlacklist
*
* @param {DOMString} clipboardData
* @returns {Node[]}
*/
prepareClipboardData: function (clipboardData) {
var $html = $('' + clipboardData + '').contents();
var $clipboardData = this._removeIllegalClipboardElements($html);
var $all = $clipboardData.find('*').addBack();
$all.filter('table').addClass('table table-bordered');
this._wrapTDContents($all.filter('td'));
this._fillEmptyBlocks($all);
this._removeIllegalClipboardAttributes($all);
$all.filter('a').removeClass();
$all.filter('img').css('max-width', '100%');
return $clipboardData.toArray();
},
/**
* Format a 'format' block: change its tagName (eg: p -> h1).
*
* @param {string} tagName
* P, H1, H2, H3, H4, H5, H6, BLOCKQUOTE, PRE
*/
formatBlock: function (tagName) {
var self = this;
var r = this.context.invoke('editor.createRange');
if (
!r ||
!this.$editable.has(r.sc).length ||
!this.$editable.has(r.ec).length ||
this.options.isUnbreakableNode(r.sc)
) {
return;
}
var nodes = this.context.invoke('HelperPlugin.getSelectedNodes');
nodes = this.context.invoke('HelperPlugin.filterFormatAncestors', nodes);
if (!nodes.length) {
var node = this.context.invoke('editor.createRange').sc;
if (node.tagName === 'BR' || dom.isText(node)) {
node = node.parentNode;
}
nodes = [node];
}
var changedNodes = [];
_.each(nodes, function (node) {
var newNode = self.document.createElement(tagName);
$(newNode).append($(node).contents());
var attributes = $(node).prop("attributes");
_.each(attributes, function (attr) {
$(newNode).attr(attr.name, attr.value);
});
$(node).replaceWith(newNode);
changedNodes.push(newNode);
});
// Select all formatted nodes
if (changedNodes.length) {
var lastNode = changedNodes[changedNodes.length - 1];
var startNode = changedNodes[0].firstChild || changedNodes[0];
var endNode = lastNode.lastChild || lastNode;
var range = this.context.invoke('editor.setRange', startNode, 0, endNode, dom.nodeLength(endNode));
range.select();
}
},
/**
* Change the paragraph alignment of a 'format' block.
*
* @param {string} style
* justifyLeft, justifyCenter, justifyRight, justifyFull
*/
formatBlockStyle: function (style) {
var self = this;
var nodes = this.context.invoke('HelperPlugin.getSelectedNodes');
nodes = this.context.invoke('HelperPlugin.filterFormatAncestors', nodes);
var align = style === 'justifyLeft' ? 'left' :
style === 'justifyCenter' ? 'center' :
style === 'justifyRight' ? 'right' : 'justify';
_.each(nodes, function (node) {
if (dom.isText(node)) {
return;
}
var textAlign = self.window.getComputedStyle(node).textAlign;
if (align !== textAlign) {
if (align !== self.window.getComputedStyle(node.parentNode).textAlign) {
$(node).css('text-align', align);
} else {
$(node).css('text-align', '');
}
}
});
this.editable.normalize();
},
/**
* (Un-)format text: make it bold, italic, ...
*
* @param {string} tagName
* bold, italic, underline, strikethrough, superscript, subscript
*/
formatText: function (tagName) {
var self = this;
var tag = {
bold: 'B',
italic: 'I',
underline: 'U',
strikethrough: 'S',
superscript: 'SUP',
subscript: 'SUB',
} [tagName];
if (!tag) {
throw new Error(tagName);
}
var range = this.context.invoke('editor.createRange');
if (!range || !this.$editable.has(range.sc).length || !this.$editable.has(range.ec).length) {
return;
}
if (range.isCollapsed()) {
var br;
if (range.sc.tagName === 'BR') {
br = range.sc;
} else if (range.sc.firstChild && range.sc.firstChild.tagName === 'BR') {
br = range.sc.firstChild;
}
if (br) {
var emptyText = this.document.createTextNode('\u200B');
$(br).before(emptyText).remove();
range = this.context.invoke('editor.setRange', emptyText, 0, emptyText, 1);
} else {
var offset = range.so;
this.context.invoke('HelperPlugin.insertTextInline', '\u200B');
range = this.context.invoke('editor.createRange');
range.so = offset;
range.eo = offset + 1;
}
range.select();
}
var nodes = this.context.invoke('HelperPlugin.getSelectedNodes');
var texts = this.context.invoke('HelperPlugin.filterLeafChildren', nodes);
var formatted = this.context.invoke('HelperPlugin.filterFormatAncestors', nodes);
var start = this.context.invoke('HelperPlugin.firstLeaf', nodes[0]);
var end = this.context.invoke('HelperPlugin.lastLeaf', nodes[nodes.length - 1]);
function containsOnlySelectedText(node) {
return _.all(node.childNodes, function (n) {
return _.any(texts, function (t) {
return n === t && !(dom.isText(n) && n.textContent === '');
}) && containsOnlySelectedText(n);
});
}
function containsAllSelectedText(node) {
return _.all(texts, function (t) {
return _.any(node.childNodes, function (n) {
return n === t && !(dom.isText(n) && n.textContent === '') || containsAllSelectedText(n);
});
});
}
var nodeAlreadyStyled = [];
var toStyled = [];
var notStyled = _.filter(texts, function (text, index) {
if (toStyled.indexOf(text) !== -1 || nodeAlreadyStyled.indexOf(text) !== -1) {
return;
}
nodeAlreadyStyled.push(text);
end = text;
var styled = dom.ancestor(text, function (node) {
return node.tagName === tag;
});
if (styled) {
if (
!/^\u200B$/.test(text.textContent) &&
containsAllSelectedText(styled) &&
containsOnlySelectedText(styled)
) {
// Unwrap all contents
nodes = $(styled).contents();
$(styled).before(nodes).remove();
nodeAlreadyStyled.push.apply(nodeAlreadyStyled, nodes);
end = _.last(nodeAlreadyStyled);
} else {
var options = {
isSkipPaddingBlankHTML: true,
isNotSplitEdgePoint: true,
};
if (
nodeAlreadyStyled.indexOf(text.nextSibling) === -1 &&
!dom.isRightEdgeOf(text, styled)
) {
options.nextText = false;
var point = self.context.invoke('HelperPlugin.makePoint', text, dom.nodeLength(text));
if (dom.isMedia(text)) {
point = dom.nextPoint(point);
}
var next = self.context.invoke('HelperPlugin.splitTree', styled, point, options);
nodeAlreadyStyled.push(next);
}
if (
nodeAlreadyStyled.indexOf(text.previousSibling) === -1 &&
!dom.isLeftEdgeOf(text, styled)
) {
options.nextText = true;
var textPoint = self.context.invoke('HelperPlugin.makePoint', text, 0);
text = self.context.invoke('HelperPlugin.splitTree', styled, textPoint, options);
nodeAlreadyStyled.push(text);
if (index === 0) {
start = text;
}
end = text;
}
var toRemove = dom.ancestor(text, function (n) {
return n.tagName === tag;
});
if (toRemove) {
// Remove generated empty elements
if (
toRemove.nextSibling &&
self.context.invoke('HelperPlugin.isBlankNode', toRemove.nextSibling)
) {
$(toRemove.nextSibling).remove();
}
if (
toRemove.previousSibling &&
self.context.invoke('HelperPlugin.isBlankNode', toRemove.previousSibling)
) {
$(toRemove.previousSibling).remove();
}
// Unwrap the element
nodes = $(toRemove).contents();
$(toRemove).before(nodes).remove();
nodeAlreadyStyled.push.apply(nodeAlreadyStyled, nodes);
end = _.last(nodeAlreadyStyled);
}
}
}
if (dom.ancestor(text, function (node) {
return toStyled.indexOf(node) !== -1;
})) {
return;
}
var node = text;
while (
node && node.parentNode &&
formatted.indexOf(node) === -1 &&
formatted.indexOf(node.parentNode) === -1
) {
node = node.parentNode;
}
if (node !== text) {
if (containsAllSelectedText(node)) {
toStyled.push.apply(toStyled, texts);
node = text;
} else if (!containsOnlySelectedText(node)) {
node = text;
}
}
if (toStyled.indexOf(node) === -1) {
toStyled.push(node);
}
return !styled;
});
toStyled = _.uniq(toStyled);
if (notStyled.length) {
nodes = [];
var toMerge = [];
_.each(toStyled, function (node) {
var next = true;
if (node.nextSibling && node.nextSibling.tagName === tag) {
$(node.nextSibling).prepend(node);
next = false;
}
if (node.previousSibling && node.previousSibling.tagName === tag) {
$(node.previousSibling).append(node);
}
if (node.parentNode && node.parentNode.tagName !== tag) {
var styled = self.document.createElement(tag);
if (node.tagName) {
$(styled).append(node.childNodes);
$(node).append(styled);
} else {
$(node).before(styled);
styled.appendChild(node);
}
}
// Add adjacent nodes with same tagName to list of nodes to merge
if (
node.parentNode && node.parentNode[next ? 'nextSibling' : 'previousSibling'] &&
node.parentNode.tagName === node.parentNode[next ? 'nextSibling' : 'previousSibling'].tagName
) {
toMerge.push(next ? node.parentNode : node.parentNode.previousSibling);
}
});
// Merge what needs merging
while (toMerge.length) {
this.context.invoke('HelperPlugin.deleteEdge', toMerge.pop(), 'next');
}
}
range = this.context.invoke('editor.setRange', start, 0, end, dom.nodeLength(end));
if (range.sc === range.ec && range.sc.textContent === '\u200B') {
range.so = range.eo = 1;
}
range.select();
this.editable.normalize();
},
/**
* Remove format on the current range.
*
* @see _isParentRemoveFormatCandidate
*/
removeFormat: function () {
this._selectCurrentIfCollapsed();
var selectedText = this.context.invoke('HelperPlugin.getSelectedText');
var selectedIcons = this.context.invoke('HelperPlugin.getSelectedNodes');
selectedIcons = _.filter(selectedIcons, dom.isIcon);
if (!selectedText.length && !selectedIcons.length) {
return;
}
_.each(selectedIcons, this._removeIconFormat.bind(this));
_.each(selectedText, this._removeTextFormat.bind(this));
var startNode = selectedText[0];
var endNode = selectedText[selectedText.length - 1];
this.context.invoke('editor.setRange', startNode, 0, endNode, dom.nodeLength(endNode)).select();
this.editable.normalize();
this.context.invoke('editor.saveRange');
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Remove the non-whitelisted or blacklisted
* top level elements from clipboard data.
*
* @see clipboardWhitelist
* @see clipboardBlacklist
*
* @private
* @param {JQuery} $clipboardData
* @returns {Object} {$clipboardData: JQuery, didRemoveNodes: Boolean}
*/
_cleanClipboardRoot: function ($clipboardData) {
var didRemoveNodes = false;
var whiteList = this._clipboardWhitelist();
var blackList = this._clipboardBlacklist();
var $fakeParent = $(this.document.createElement('div'));
_.each($clipboardData, function (node) {
var isWhitelisted = dom.isText(node) || $(node).filter(whiteList.join(',')).length;
var isBlacklisted = $(node).filter(blackList.join(',')).length;
if (!isWhitelisted || isBlacklisted) {
$fakeParent.append(node.childNodes);
didRemoveNodes = true;
} else {
$fakeParent.append(node);
}
});
return {
$clipboardData: $fakeParent.contents(),
didRemoveNodes: didRemoveNodes,
};
},
/**
* Return a list of jQuery selectors for prohibited nodes on paste.
*
* @private
* @returns {String[]}
*/
_clipboardBlacklist: function () {
return ['.Apple-interchange-newline'];
},
/**
* Return a list of jQuery selectors for exclusively authorized nodes on paste.
*
* @private
* @returns {String[]}
*/
_clipboardWhitelist: function () {
var listSels = ['ul', 'ol', 'li'];
var styleSels = ['i', 'b', 'u', 'em', 'strong'];
var tableSels = ['table', 'th', 'tbody', 'tr', 'td'];
var miscSels = ['img', 'br', 'a', '.fa'];
return this.options.styleTags.concat(listSels, styleSels, tableSels, miscSels);
},
/**
* Return a list of attribute names that are exclusively authorized on paste.
*
* @private
* @returns {String[]}
*/
_clipboardWhitelistAttr: function () {
return ['class', 'href', 'src'];
},
/**
* Fill up empty block elements with BR elements so the carret can enter them.
*
* @private
* @param {JQuery} $els
*/
_fillEmptyBlocks: function ($els) {
var self = this;
$els.filter(function (i, n) {
return self.context.invoke('HelperPlugin.isNodeBlockType', n) && !n.childNodes;
}).append(this.document.createElement('br'));
},
/**
* Get all non-whitelisted or blacklisted elements from clipboard data.
*
* @private
* @param {JQuery} $clipboardData
* @returns {JQuery}
*/
_filterIllegalClipboardElements: function ($clipboardData) {
return $clipboardData.find('*').addBack()
.not(this._clipboardWhitelist().join(','))
.addBack(this._clipboardBlacklist().join(','))
.filter(function () {
return !dom.isText(this);
});
},
/**
* Get a legal point to paste at, from the current range's start point.
*
* @private
* @returns {Object} {node: Node, offset: Number}
*/
_getPastePoint: function () {
var point = this.context.invoke('editor.createRange').getStartPoint();
var offsetChild = point.node.childNodes[point.offset];
point = offsetChild ? this.context.invoke('HelperPlugin.makePoint', offsetChild, 0) : point;
return dom.nextPointUntil(point, this._isPastePointLegal.bind(this));
},
/**
* Insert nodes at a point. Insert them inline if the first node is inline
* and pasting inline is legal at that point.
*
* @private
* @param {Node[]} nodes
* @param {Object} point {node: Node, offset: Number}
*/
_insertNodesAt: function (nodes, point) {
var canInsertInline = dom.isText(point.node) || point.node.tagName === 'BR' || dom.isMedia(point.node);
var $fakeParent = $(this.document.createElement('div'));
$fakeParent.append(nodes);
if (dom.isInline(nodes[0]) && canInsertInline) {
point.node = point.node.tagName ? point.node : point.node.splitText(point.offset);
$(point.node).before($fakeParent.contents());
} else {
this.context.invoke('HelperPlugin.insertBlockNode', $fakeParent[0]);
}
$fakeParent.contents().unwrap();
},
/**
* Return true if the parent of the given node is a removeFormat candidate:
* - It is a removeFormat candidate as defined by W3C
* - It is contained within the editable area
* - It is not unbreakable
*
* @see formatTags the list of removeFormat candidates as defined by W3C
*
* @private
* @param {Node} node
*/
_isParentRemoveFormatCandidate: function (node) {
var parent = node.parentNode;
if (!parent) {
return false;
}
var isEditableOrAbove = parent && (parent === this.editable || $.contains(parent, this.editable));
var isUnbreakable = parent && this.options.isUnbreakableNode(parent);
var isRemoveFormatCandidate = parent && parent.tagName && this.formatTags.indexOf(parent.tagName.toLowerCase()) !== -1;
return parent && !isEditableOrAbove && !isUnbreakable && isRemoveFormatCandidate;
},
/**
* Return true if it's legal to paste nodes at the given point:
* if the point is not within a void node and the point is not unbreakable.
*
* @private
* @param {Object} point {node: Node, offset: Number}
* @returns {Boolean}
*/
_isPastePointLegal: function (point) {
var node = point.node;
var isWithinVoid = false;
if (node.parentNode) {
isWithinVoid = dom.isVoid(node.parentNode) || $(node.parentNode).filter('.fa').length;
}
return !isWithinVoid && !this.options.isUnbreakableNode(point.node);
},
/**
* @private
* @param {Object} point {node: Node, offset: Number}
* @returns {Boolean}
*/
_isPointInAnchor: function (point) {
var ancestor = dom.ancestor(point.node, dom.isAnchor);
return !ancestor || ancestor === this.editable;
},
/**
* Check a list of nodes and merges all adjacent ULs together:
* [ul, ul, p, ul, ul] will return [ul, p, ul], with the li's of
* nodes[1] and nodes[4] appended to nodes[0] and nodes[3].
*
* @private
* @param {Node[]} nodes
* @return {Node[]} the remaining, merged nodes
*/
_mergeAdjacentULs: function (nodes) {
var res = [];
var prevNode;
_.each(nodes, function (node) {
prevNode = res[res.length - 1];
if (prevNode && node.tagName === 'UL' && prevNode.tagName === 'UL') {
$(prevNode).append(node.childNodes);
} else {
res.push(node);
}
});
return res;
},
/**
* Remove an icon's format (colors, font size).
*
* @private
* @param {Node} icon
*/
_removeIconFormat: function (icon) {
$(icon).css({
color: '',
backgroundColor: '',
fontSize: '',
});
var reColorClasses = /(^|\s+)(bg|text)-\S*|/g;
icon.className = icon.className.replace(reColorClasses, '').trim();
},
/**
* Remove non-whitelisted attributes from clipboard.
*
* @private
* @param {JQuery} $els
*/
_removeIllegalClipboardAttributes: function ($els) {
var self = this;
$els.each(function () {
var $node = $(this);
_.each(_.pluck(this.attributes, 'name'), function (attribute) {
if (self._clipboardWhitelistAttr().indexOf(attribute) === -1) {
$node.removeAttr(attribute);
}
});
}).removeClass('o_editable o_not_editable');
},
/**
* Remove non-whitelisted and blacklisted elements from clipboard data.
*
* @private
* @param {JQuery} $clipboardData
* @returns {JQuery}
*/
_removeIllegalClipboardElements: function ($clipboardData) {
var root = true;
$clipboardData = $clipboardData.not('meta').not('style').not('script');
var $badNodes = this._filterIllegalClipboardElements($clipboardData);
do {
if (root) {
root = false;
var cleanData = this._cleanClipboardRoot($clipboardData);
$clipboardData = cleanData.$clipboardData;
root = cleanData.didRemoveNodes;
} else {
this._removeNodesPreserveContents($badNodes);
}
$badNodes = this._filterIllegalClipboardElements($clipboardData);
} while ($badNodes.length);
return $clipboardData;
},
/**
* Remove nodes from the DOM while preserving their contents if any.
*
* @private
* @param {JQuery} $nodes
*/
_removeNodesPreserveContents: function ($nodes) {
var $contents = $nodes.contents();
if ($contents.length) {
$contents.unwrap();
} else {
$nodes.remove();
}
},
/**
* Remove a text node's format: remove its style parents (b, i, u, ...).
*
* @private
* @param {Node} textNode
*/
_removeTextFormat: function (textNode) {
var node = textNode;
while (this._isParentRemoveFormatCandidate(node)) {
this.context.invoke('HelperPlugin.splitAtNodeEnds', node);
$(node.parentNode).before(node).remove();
}
},
/**
* Select the whole current node if the range is collapsed
*
* @private
*/
_selectCurrentIfCollapsed: function () {
var range = this.context.invoke('editor.createRange');
if (!range.isCollapsed()) {
return;
}
this.context.invoke('editor.setRange', range.sc, 0, range.sc, dom.nodeLength(range.sc)).select();
this.context.invoke('editor.saveRange');
},
/**
* Prevent inline nodes directly in TDs by wrapping them in P elements.
*
* @private
* @param {JQuery} $tds
*/
_wrapTDContents: function ($tds) {
var self = this;
var $inlinesInTD = $tds.contents().filter(function () {
return !self.context.invoke('HelperPlugin.isNodeBlockType', this);
});
var parentsOfInlinesInTD = [];
_.each($inlinesInTD, function (n) {
parentsOfInlinesInTD.push(self.context.invoke('HelperPlugin.firstBlockAncestor', n));
});
$($.unique(parentsOfInlinesInTD)).wrapInner(this.document.createElement('p'));
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Handle paste events to permit cleaning/sorting of the data before pasting.
*
* @private
* @param {SummernoteEvent} se
* @param {jQueryEvent} e
*/
_onPaste: function (se, e) {
se.preventDefault();
se.stopImmediatePropagation();
e.preventDefault();
e.stopImmediatePropagation();
this.context.invoke('editor.beforeCommand');
// Clean up
var clipboardData = e.originalEvent.clipboardData.getData('text/html');
if (clipboardData) {
clipboardData = this.prepareClipboardData(clipboardData);
} else {
clipboardData = e.originalEvent.clipboardData.getData('text/plain');
// get that text as an array of text nodes separated by
where needed
var allNewlines = /\n/g;
clipboardData = _.str.trim(clipboardData)
.replace(//g, '>')
.replace(allNewlines, '
');
clipboardData = $('
' + clipboardData + '
').contents().toArray();
}
// Delete selection
this.context.invoke('HelperPlugin.deleteSelection');
// Insert the nodes
this.pasteNodes(clipboardData);
this.context.invoke('HelperPlugin.normalize');
this.context.invoke('editor.saveRange');
this.context.invoke('editor.afterCommand');
},
});
registry.add('TextPlugin', TextPlugin);
return TextPlugin;
});