odoo.define('web_editor.wysiwyg.plugin.helper', function (require) {
'use strict';
var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract');
var Dialog = require('web.Dialog');
var registry = require('web_editor.wysiwyg.plugin.registry');
var dom = $.summernote.dom;
var HelperPlugin = AbstractPlugin.extend({
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* This dictionary contains oft-used regular expressions,
* for performance and readability purposes. It can be
* accessed and extended by the getRegex() method.
*/
regex: {
char: {
noflag: /\S|\u00A0|\u200B/,
},
emptyElemWithBR: {
noflag: /^\s*
\s*$/,
},
endInvisible: {
noflag: /\u200B$/,
},
endNotChar: {
noflag: /[^\S\u00A0\u200B]+$/,
},
endSingleSpace: {
noflag: /[\S\u00A0\u200B]\s$/,
},
endSpace: {
noflag: /\s+$/,
},
invisible: {
noflag: /\u200B/,
},
jinja: {
noflag: /(^|\n)\s*%\send|%\sset/,
},
notWhitespace: {
noflag: /\S/,
},
onlyEmptySpace: {
noflag: /^[\s\u00A0\u200B]*(
)?[\s\u00A0\u200B]*$/,
},
semicolon: {
noflag: / ?; ?/,
},
space: {
noflag: /\s+/,
g: /\s+/g,
},
spaceOrNewline: {
noflag: /[\s\n\r]+/,
g: /[\s\n\r]+/g,
},
startAndEndInvisible: {
noflag: /^\u200B|\u200B$/,
g: /^\u200B|\u200B$/g,
},
startAndEndSpace: {
noflag: /^\s+|\s+$/,
g: /^\s+|\s+$/g,
},
startAndEndSemicolon: {
noflag: /^ ?;? ?| ?;? ?$/,
},
startInvisible: {
noflag: /^\u200B/,
},
startNotChar: {
noflag: /^[^\S\u00A0\u200B]+/,
},
startSingleSpace: {
noflag: /^\s[\S\u00A0\u200B]/,
},
startSpace: {
noflag: /^\s+/,
},
},
/**
* Compares two nodes to see if they are similar.
* "Similar" means that they have the same tag, styles, classes and attributes.
*
* @param {Node} node
* @param {Node} otherNode
* @returns {Boolean} true if the nodes are similar
*/
compareNodes: function (node, otherNode) {
if (!otherNode || !node) {
return false;
}
if (node.tagName !== otherNode.tagName) {
return false;
}
if (dom.isText(node)) {
return true;
}
this.removeBlankAttrs(node);
this.removeBlankAttrs(otherNode);
this.orderClass(node);
this.orderStyle(node);
this.orderClass(otherNode);
this.orderStyle(otherNode);
if (node.attributes.length !== otherNode.attributes.length) {
return false;
}
for (var i = 0; i < node.attributes.length; i++) {
var attr = node.attributes[i];
var otherAttr = otherNode.attributes[i];
if (attr.name !== otherAttr.name || attr.value !== otherAttr.value) {
return false;
}
}
return true;
},
/**
* Returns the number of leading breakable space in the textNode.
* Note: returns 0 if the node is not a textNode.
*
* @param {Node} node
*/
countLeadingBreakableSpace: function (node) {
if (!dom.isText(node)) {
return 0;
}
var clone = $(node).clone()[0];
var breakableSpace = this.removeExtremeBreakableSpace(clone, 0).start;
return breakableSpace === 1 ? 0 : breakableSpace;
},
/**
* Returns the number of trailing breakable space in the textNode.
* Note: returns 0 if the node is not a textNode.
*
* @param {Node} node
*/
countTrailingBreakableSpace: function (node) {
if (!dom.isText(node)) {
return 0;
}
var clone = $(node).clone()[0];
var breakableSpace = this.removeExtremeBreakableSpace(clone, 0).end;
return breakableSpace === 1 ? 0 : breakableSpace;
},
/**
* Remove the dom between 2 points (respecting unbreakable rules).
* Returns an object::
*
* {
* node: pointA.node (updated if necessary),
* offset: pointA.offset (updated if necessary),
* changed: bool (whether changes were applied)
* }
*
* @param {Object} pointA
* @param {Node} pointA.node
* @param {number} pointA.offset
* @param {Object} pointB
* @param {Node} pointB.node
* @param {number} pointB.offset
* @returns {Object} {node, offset, changed}
*/
deleteBetween: function (pointA, pointB) {
var self = this;
if (pointB.node.childNodes[pointB.offset]) {
var firstLeaf = this.firstLeaf(pointB.node.childNodes[pointB.offset]);
pointB = this.makePoint(firstLeaf, 0);
}
if (pointB.node.tagName && pointB.node.tagName !== 'BR' && pointB.offset >= dom.nodeLength(pointB.node)) {
pointB = dom.nextPoint(pointB);
}
var changed;
var commonAncestor = dom.commonAncestor(pointA.node, pointB.node);
var ecAncestor = dom.ancestor(pointB.node, function (node) {
return node === commonAncestor || self.options.isUnbreakableNode(node.parentNode);
});
var next = this.splitTree(ecAncestor, pointB, {
nextText: true,
});
var scAncestor = dom.ancestor(pointA.node, function (node) {
return node === commonAncestor || self.options.isUnbreakableNode(node.parentNode);
});
if (dom.isIcon(pointA.node)) {
pointA = dom.prevPoint(pointA);
}
this.splitTree(scAncestor, pointA, {
nextText: true,
});
pointA.offset = dom.nodeLength(pointA.node);
var nodes = [];
dom.nextPointUntil(pointA, function (point) {
if (point.node === next || !point.node) {
return true;
}
if (dom.isText(point.node) && point.offset) {
return;
}
var target = point.node.childNodes[point.offset] || point.node;
if (target === pointA.node || $.contains(target, pointA.node) || target === next || $.contains(target, next)) {
return;
}
if (
nodes.indexOf(target) === -1 && !dom.ancestor(target, function (target) {
return nodes.indexOf(target) !== -1;
})
) {
nodes.push(target);
}
});
$(nodes).remove();
changed = !!nodes.length;
var toMerge = changed && pointA.node.parentNode !== next.parentNode;
var point = this.makePoint(this.firstLeaf(next), 0);
if (nodes.length > 1 || nodes.length && !dom.isText(nodes[0])) {
point = this.removeEmptyInlineNodes(point);
}
// Remove whole li/ul/ol if deleted all contents of li/ul/ol
var ul = dom.ancestor(next, function (n) {
return n.tagName === 'UL' || n.tagName === 'OL';
});
if (ul && next[dom.isText(next) ? 'textContent' : 'innerHTML'] === '' && pointA.node !== next.previousSibling) {
var toRemove = next;
while (
toRemove !== ul && toRemove.parentNode &&
!this.options.isUnbreakableNode(toRemove.parentNode) &&
this.isBlankNode(toRemove.parentNode)
) {
toRemove = toRemove.parentNode;
}
$(toRemove).remove();
}
if (!$.contains(this.editable, pointA.node)) {
pointA = point;
}
if (toMerge) {
pointA = this.deleteEdge(pointA.node, 'next') || pointA;
}
return {
node: pointA.node,
offset: pointA.offset,
changed: changed,
};
},
/**
* Remove the edge between a node and its sibling
* (= merge the nodes, respecting unbreakable rules).
*
* @param {Node} node
* @param {String('next'|'prev')} direction
* @param {Boolean} doNotTryNonSim true to not try merging non-similar nodes
* @returns {Object} {node, offset}
*/
deleteEdge: function (node, direction, doNotTryNonSim) {
var prevOrNext = direction === 'prev' ? 'previousSibling' : 'nextSibling';
var result = false;
var startN = node;
if (node.tagName === 'BR' && node.nextSibling && !(dom.isText(node.nextSibling) && !this.isVisibleText(node.nextSibling))) {
node = node.nextSibling;
node = this.firstLeaf(node);
}
var nodes = [];
var next;
while (node && node !== this.editable && !this.options.isUnbreakableNode(node)) {
nodes.push(node);
next = node[prevOrNext];
while (next && !next.tagName) {
if (!this.getRegex('char').test(next.textContent)) {
next = next[prevOrNext];
continue;
}
break;
}
if (next) {
break;
}
node = node.parentNode;
}
if (next && next.tagName === 'TABLE') {
return this.makePoint(node, 0);
}
var ifBrRemovedAndMerge = !_.filter(nodes, this.isNodeBlockType.bind(this)).length;
var brRemoved = false;
var range = this.context.invoke('editor.createRange');
var spaceToRemove = [];
while ((node = nodes.pop())) {
next = node[prevOrNext];
while (next && !next.tagName) {
if (!this.getRegex('char').test(next.textContent)) {
spaceToRemove.push(next);
next = next[prevOrNext];
continue;
}
break;
}
if (
!next ||
!(node.tagName || next.tagName === 'BR') ||
!next.tagName
) {
continue;
}
if (!brRemoved && next.tagName === 'BR' && (!next[prevOrNext] || this.compareNodes(node, next[prevOrNext]))) {
var newNext = next[prevOrNext];
$(next).remove();
next = newNext;
var offset = (next ? direction === 'prev' : direction === 'next') ? dom.nodeLength(next) : 0;
result = this.makePoint(next || node, offset);
if (!ifBrRemovedAndMerge) {
continue;
}
brRemoved = true;
ifBrRemovedAndMerge = false;
}
if (!this.compareNodes(node, next)) {
continue;
}
_.each(spaceToRemove, function (space) {
$(space).remove();
});
spaceToRemove = [];
next = node[prevOrNext];
var $next = $(next);
if (next.tagName) {
var textNode;
var nextTextNode;
var deep;
if (direction === 'prev') {
textNode = this.firstLeaf(node);
if (!textNode.tagName && !dom.ancestor(textNode, dom.isPre)) {
this.removeExtremeBreakableSpace(textNode);
range.so = range.eo = 0;
nextTextNode = this.lastLeaf(next);
if (!nextTextNode.tagName && !dom.ancestor(nextTextNode, dom.isPre)) {
this.removeExtremeBreakableSpace(nextTextNode);
}
}
deep = this.lastLeaf(next);
result = this.makePoint(deep, dom.nodeLength(deep));
if (
this.getRegex('char').test(node.textContent) || node.childElementCount > 1 ||
node.firstElementChild && node.firstElementChild.tagName !== "BR"
) {
$next.append($(node).contents());
}
$(node).remove();
} else {
nextTextNode = this.firstLeaf(next);
if (!nextTextNode.tagName && !dom.ancestor(nextTextNode, dom.isPre)) {
this.removeExtremeBreakableSpace(nextTextNode);
textNode = this.lastLeaf(node);
if (!textNode.tagName && !dom.ancestor(textNode, dom.isPre)) {
this.removeExtremeBreakableSpace(textNode);
range.so = range.eo = dom.nodeLength(node);
}
}
if (node.innerHTML.trim() === '
') {
$(node).contents().remove();
}
deep = this.lastLeaf(node);
result = this.makePoint(deep, dom.nodeLength(deep));
$(node).append($next.contents());
$next.remove();
}
continue;
} else if (!this.getRegex('char').test(next.textContent)) {
result = this.makePoint(node, direction === 'prev' ? 0 : dom.nodeLength(node));
$next.remove();
continue;
}
break;
}
if (!result && startN && !doNotTryNonSim) {
result = this.deleteNonSimilarEdge(startN, direction);
}
return result;
},
/**
* Find and delete the previous/next non-similar edge if possible.
* "Similar" means that they have the same tag, styles, classes and attributes.
*
* @param {Node} node
* @param {String} direction 'prev' or 'next'
* @returns {false|Object} {node, offset}
*/
deleteNonSimilarEdge: function (node, direction) {
var next = node[direction === 'next' ? 'nextSibling' : 'previousSibling'];
while (
next && dom.isText(next) &&
this.getRegexBlank({
space: true,
invisible: true,
}).test(next.textContent)
) {
next = next[direction === 'next' ? 'nextSibling' : 'previousSibling'];
}
if (next) {
return;
}
node = this.firstBlockAncestor(node);
if (this.options.isUnbreakableNode(node)) {
return;
}
var point = this.makePoint(node, direction === 'prev' ? 0 : dom.nodeLength(node));
var otherBlock = this.findNextBlockToMerge(point.node, direction);
if (!otherBlock) {
return;
}
var blockToMergeFrom = direction === 'next' ? otherBlock : point.node;
var blockToMergeInto = direction === 'next' ? point.node : otherBlock;
// empty tag are removed
if (
this.getRegexBlank({
space: true,
newline: true,
}).test(blockToMergeInto.textContent) &&
!$(blockToMergeInto).find('.fa').length && $(blockToMergeInto).find('br').length <= 1
) {
$(blockToMergeInto).remove();
return this.makePoint(this.firstLeaf(blockToMergeFrom), 0);
}
return this.mergeNonSimilarBlocks(blockToMergeFrom, blockToMergeInto);
},
/**
* Deletes the contents of the selected DOM.
*
* @returns {Boolean} true if there was a selection to delete
*/
deleteSelection: function () {
var range = this.context.invoke('editor.createRange');
if (range.isCollapsed()) {
return false;
}
var point = this.deleteBetween(range.getStartPoint(), range.getEndPoint());
point = this.fillEmptyNode(point);
range = this.context.invoke('editor.setRange', point.node, point.offset);
range = range.select();
this.editable.normalize();
this.context.invoke('editor.saveRange');
// remove tooltip when remove DOM nodes
$('body > .tooltip').tooltip('hide');
return true;
},
/**
* Fill up an empty node so as to allow the carret to go inside it.
* A block node will be filled with a
, with the offset before it.
* An inline node will be filled with two zero-width spaces, with the offset in between the two.
* Returns the given point (with the completed node and the updated offset).
*
* @param {Object} point {node, offset}
* @returns {Object} {node, offset}
*/
fillEmptyNode: function (point) {
if (
!point.node.tagName && this.getRegexBlank({
space: true,
invisible: true,
nbsp: true,
}).test(point.node.parentNode.innerHTML)
) {
point.node = point.node.parentNode;
point.offset = 0;
}
if (
point.node.tagName && point.node.tagName !== 'BR' &&
this.getRegexBlank({
space: true,
invisible: true,
nbsp: true,
}).test(point.node.innerHTML)
) {
var text = this.document.createTextNode('');
point.node.innerHTML = '';
point.node.appendChild(text);
point.node = text;
point.offset = 0;
}
if (point.node.parentNode.innerHTML === '') {
if (this.isNodeBlockType(point.node.parentNode)) {
var node = point.node.parentNode;
node.innerHTML = '
';
point.node = node.firstChild;
point.offset = 0;
} else {
point.node.textContent = '\u200B\u200B';
point.offset = 1;
}
}
return point;
},
/**
* Get the "format" ancestors list of nodes.
* In this context, a "format" node is understood as
* an editable block or an editable element expecting text
* (eg.: p, h1, span).
*
* @param {Node[]} nodes
* @returns {Node[]}
*/
filterFormatAncestors: function (nodes) {
var self = this;
var selectedNodes = [];
_.each(this.filterLeafChildren(nodes), function (node) {
var ancestor = dom.ancestor(node, function (node) {
return dom.isCell(node) || (
!self.options.isUnbreakableNode(node) &&
(self.isFormatNode(node) || self.isNodeBlockType(node))
) && self.editable !== node;
});
if (!ancestor) {
ancestor = node;
}
if (dom.isCell(ancestor)) {
ancestor = node;
}
if (ancestor && selectedNodes.indexOf(ancestor) === -1) {
selectedNodes.push(ancestor);
}
});
return selectedNodes;
},
/**
* Get the "leaf" children of a list of nodes.
* In this context, a "leaf" is understood as
* either a text node or a node that doesn't expect text contents.
*
* @param {Node[]} nodes
* @returns {Node[]}
*/
filterLeafChildren: function (nodes) {
var self = this;
return _.compact(_.map(nodes, function (node) {
if (node.firstChild) {
node = node.firstChild;
}
if (
node.tagName === "BR" ||
self.isVisibleText(node) ||
dom.isFont(node) ||
dom.isImg(node) ||
dom.isDocument(node)
) {
return node;
}
}));
},
/**
* Find the previous/next non-similar block to merge with.
* "Similar" means that they have the same tag, styles, classes and attributes.
*
* @param {Node} node
* @param {String} direction 'prev' or 'next
* @returns {false|Node}
*/
findNextBlockToMerge: function (node, direction) {
var self = this;
var startNode = node;
var mergeableTags = this.options.styleTags.join(', ') + ', li';
var blockToMerge = false;
var li = dom.ancestor(node, function (n) {
return n !== node && self.isNodeBlockType(n) || dom.isLi(n);
});
li = li && dom.isLi(li) ? li : undefined;
if (li && direction === 'next') {
if (li.nextElementSibling) {
node = li;
} else {
node = dom.ancestor(node, function (n) {
return ((n.tagName === 'UL' || n.tagName === 'OL') && n.nextElementSibling);
});
}
}
if (!node || !node[direction === 'next' ? 'nextElementSibling' : 'previousElementSibling']) {
return false;
}
node = node[direction === 'next' ? 'nextElementSibling' : 'previousElementSibling'];
var ulFoldedSnippetNode = dom.ancestor(node, function (n) {
return $(n).hasClass('o_ul_folded');
});
var ulFoldedSnippetStartNode = dom.ancestor(startNode, function (n) {
return $(n).hasClass('o_ul_folded');
});
if (
(this.options.isUnbreakableNode(node) && (!ulFoldedSnippetNode || ulFoldedSnippetNode === this.editable)) &&
this.options.isUnbreakableNode(startNode) && (!ulFoldedSnippetStartNode || ulFoldedSnippetStartNode === this.editable)
) {
return false;
}
node = this.firstBlockAncestor(node);
li = dom.ancestor(node, function (n) {
return n !== node && self.isNodeBlockType(n) || dom.isLi(n);
});
li = li && dom.isLi(li) ? li : undefined;
node = li || node;
if (node.tagName === 'UL' || node.tagName === 'OL') {
node = node[direction === 'next' ? 'firstElementChild' : 'lastElementChild'];
}
if (this.options.isUnbreakableNode(node)) {
return false;
}
if (node === startNode || $(node).has(startNode).length || $(startNode).has(node).length) {
return false;
}
var $mergeable = $(node).find('*').addBack()
.filter(mergeableTags)
.filter(function (i, n) {
if (!(n.tagName === 'LI' && $(n).find(mergeableTags).length)) {
return n;
}
});
if ($mergeable.length) {
blockToMerge = $mergeable[direction === 'next' ? 'first' : 'last']()[0] || false;
}
return blockToMerge;
},
/**
* Get the first ancestor of a node, that is of block type (or itself).
*
* @param {Node} node
* @returns {Node}
*/
firstBlockAncestor: function (node) {
var self = this;
return dom.ancestor(node, function (n) {
return self.isNodeBlockType(n);
});
},
/**
* Get the first leaf of a node, that is editable and not a media.
* In this context, a leaf node is understood as a childless node.
*
* @param {Node} node
* @returns {Node}
*/
firstLeaf: function (node) {
while (node.firstChild && !dom.isMedia(node) && this.options.isEditableNode(node)) {
node = node.firstChild;
}
return node;
},
/**
* Get the first leaf of a node, that is an element and not a BR.
* In this context, a leaf node is understood as a childless node.
*
* @param {Node} node
* @returns {Node} node
*/
firstNonBRElementLeaf: function (node) {
while (node.firstElementChild && node.firstElementChild.tagName !== 'BR') {
node = node.firstElementChild;
}
return node;
},
/**
* Returns the node targeted by a path
*
* @param {Object[]} path list of object (tagName, offset)
* @returns {Node}
*/
fromPath: function (path) {
var node = this.editable;
var to;
path = path.slice();
while ((to = path.shift())) {
node = _.filter(node.childNodes, function (node) {
return !to.tagName && node.tagName === 'BR' || node.tagName === to.tagName;
})[to.offset];
}
return node;
},
/**
* Returns (and creates if necessary) a regular expression.
* If a regular expression with the given name exists, simply returns it.
* Otherwise, creates a new one with the given name, exp and flag.
*
* @param {String} name
* @param {String} [flag] optional
* @param {String} [exp] optional
* @returns {RegExp}
*/
getRegex: function (name, flag, exp) {
var flagName = flag || 'noflag';
flag = flag || '';
// If the regular expression exists, but not with this flag:
// retrieve whichever version of it and apply the new flag to it,
// then save that new version in the `regex` object.
if (this.regex[name] && !this.regex[name][flagName]) {
if (exp) {
console.warn("A regular expression already exists with the name: " + name + ". The expression passed will be ignored.");
}
var firstVal = this.regex[name][Object.keys(this.regex[name])[0]];
this.regex[name][flagName] = new RegExp(firstVal, flag);
} else if (!this.regex[name]) {
// If the regular expression does not exist:
// save it into the `regex` object, with the name, expression
// and flag passed as arguments (if any).
if (!exp) {
throw new Error("Cannot find a regular expression with the name " + name + ". Pass an expression to create it.");
}
this.regex[name] = {};
this.regex[name][flagName] = new RegExp(exp, flag);
}
return this.regex[name][flagName];
},
/**
* Returns (and creates if necessary) a regular expression
* targetting a string made ONLY of some combination of the
* characters enabled with options.
* If a regular expression with the given options exists, simply returns it.
* eg: getRegexBlank({space: true, nbsp: true}) => /^[\s\u00A0]*$/
*
* @param {Object} [options] optional
* @param {Boolean} options.not ^ (not all that follows)
* @param {Boolean} options.space \s (a whitespace)
* @param {Boolean} options.notspace \S (not a whitespace)
* @param {Boolean} options.nbsp \u00A0 (a non-breakable space)
* @param {Boolean} options.invisible \u200B (a zero-width character)
* @param {Boolean} options.newline \n|\r (a new line or a carriage return)
* @param {Boolean} options.atLeastOne + (do not target blank strings)
* @returns {RegExp}
*/
getRegexBlank: function (options) {
options = options || {};
var charMap = {
notspace: {
name: 'NotSpace',
exp: '\\S',
},
space: {
name: 'Space',
exp: '\\s',
},
nbsp: {
name: 'Nbsp',
exp: '\\u00A0',
},
invisible: {
name: 'Invisible',
exp: '\\u200B',
},
newline: {
name: 'Newline',
exp: '\\n\\r',
},
};
var name = 'only';
var exp = '';
var atLeastOne = options.atLeastOne;
options.atLeastOne = false;
// Build the expression and its name
if (options.not) {
name += 'Not';
exp += '^';
options.not = false;
}
_.each(options, function (value, key) {
if (value && charMap[key]) {
name += charMap[key].name;
exp += charMap[key].exp;
}
});
exp = '^[' + exp + ']' + (atLeastOne ? '+' : '*') + '$';
name += atLeastOne ? 'One' : '';
return this.getRegex(name, undefined, exp);
},
/**
* Returns a list of all selected nodes in the range.
*
* @returns {Node []}
*/
getSelectedNodes: function () {
var range = this.context.invoke('editor.createRange');
if (!range.isCollapsed()) {
if (range.so && !range.sc.tagName) {
if (range.sc === range.ec) {
range.sc = range.ec = range.sc.splitText(range.so);
range.eo -= range.so;
} else {
range.sc = range.sc.splitText(range.so);
}
range.so = 0;
}
if (!range.ec.tagName && range.eo !== dom.nodeLength(range.ec) && !range.ec.tagName) {
range.ec.splitText(range.eo);
}
range.select();
}
var res = [range.sc];
var point = range.getStartPoint();
var prevNode = range.sc;
dom.nextPointUntil(point, function (pt) {
if (pt.node !== prevNode) {
var ok = true;
// Return only the smallest traversed children
_.each(res, function (n, i) {
// If pt.node is a child of res[i], replace it
if (dom.listAncestor(pt.node).indexOf(n) !== -1) {
res[i] = pt.node;
ok = false;
} else if (dom.listAncestor(n).indexOf(pt.node) !== -1) {
// Inversely, skip parents of res[i]
ok = false;
}
});
if (ok) {
res.push(pt.node);
}
}
prevNode = pt.node;
return pt.node === range.ec;
});
return _.uniq(res);
},
/**
* Returns a list of all selected text nodes in the range.
*
* @returns {Node []}
*/
getSelectedText: function () {
var self = this;
var selectedText = _.filter(this.getSelectedNodes(), function (node) {
node = self.firstLeaf(node);
return self.isVisibleText(node);
});
return _.uniq(selectedText);
},
/**
* Returns true if the value contains jinja logic.
*
* @returns {Boolean}
*/
hasJinja: function (value) {
return this.getRegex('jinja').test(value);
},
/**
* Inserts a block node (respecting the rules of unbreakable nodes).
* In order to insert the node, the DOM tree is split at the carret position.
* If there is a selection, it is deleted first.
*
* @param {Node} node
*/
insertBlockNode: function (node) {
var self = this;
var range = this.context.invoke('editor.createRange');
range = range.deleteContents();
var point = range.getStartPoint();
var unbreakable = point.node;
if (!this.options.isUnbreakableNode(point.node)) {
unbreakable = dom.ancestor(point.node, function (node) {
return self.options.isUnbreakableNode(node.parentNode) || node === self.editable;
}) || point.node;
}
if (unbreakable === point.node && !point.offset && point.node.tagName !== 'P') {
if (point.node.innerHTML === '
') {
$(point.node.firstElementChild).remove();
}
if (point.node.tagName === "BR") {
$(point.node).replaceWith(node);
} else {
point.node.append(node);
}
return;
}
if (!this.options.isUnbreakableNode(point.node)) {
var tree = dom.splitTree(unbreakable, point, {
isSkipPaddingBlankHTML: true,
isNotSplitEdgePoint: true,
});
if ((!tree || $.contains(tree, range.sc)) && (point.offset || point.node.tagName)) {
tree = tree || dom.ancestor(point.node, function (node) {
return self.options.isUnbreakableNode(node.parentNode);
});
$(tree).after(node);
} else {
$(tree).before(node);
}
} else {
// prevent unwrapped text in unbreakable
if (dom.isText(unbreakable)) {
$(unbreakable).wrap(this.document.createElement('p'));
unbreakable.splitText(point.offset);
unbreakable = unbreakable.parentNode;
point.offset = 1;
}
$(unbreakable.childNodes[point.offset]).before(node);
}
if (range.sc.innerHTML === '
') {
var clone = range.sc.cloneNode(true);
if (node.previousSibling === range.sc) {
$(node).after(clone);
} else if (node.nextSibling === range.sc) {
$(node).before(clone);
}
}
},
/**
* Inserts a string as a text node in the DOM.
* If the range is on a text node, splits the text node first.
* Otherwise just inserts the text node.
* Wraps it in a P if needed.
*
* @param {String} text
*/
insertTextInline: function (text) {
if (text === " ") {
text = "\u00A0";
}
this.editable.normalize();
this.deleteSelection();
var range = this.context.invoke('editor.createRange');
if (!range.sc.tagName && range.sc.textContent.match(/\S/)) {
var before = range.sc.textContent.slice(0, range.so);
var after = range.sc.textContent.slice(range.so);
if (
(before.length || after.length) &&
(!before.length || before[before.length - 1] === ' ') &&
(!after.length || after[0] === ' ')
) {
var startSpace = this.getRegex('startSpace');
var endSpace = this.getRegex('endSpace');
before = before.replace(endSpace, '\u00A0');
after = after.replace(startSpace, '\u00A0');
range.sc.textContent = before + after;
if (range.so > before.length) {
range.so = range.eo = before.length;
}
range.select();
}
}
range = range.deleteContents();
range = this._standardizeRangeOnEdge(range);
var textNode = this._insertTextNodeInEditableArea(range, text);
// if the text node can't be inserted in the dom (not editable area) do nothing
if (!textNode) {
return;
}
this.secureExtremeSingleSpace(range.sc);
this._wrapTextWithP(textNode);
range = this.context.invoke('editor.setRange', textNode, text.length);
range.select();
this.context.invoke('editor.saveRange');
// Clean up and make leading/trailing/multiple space visible
this.normalize();
range = this.context.invoke('editor.createRange');
var reStartBlanks = this.getRegex('startBlanks', '', '^([\\s\\u00A0\\u200B]*)');
var reEndBlanks = this.getRegex('endBlanks', '', '([\\s\\u00A0\\u200B]*)$');
var reAllNBSP = /\u00A0/g;
var reMultipleSpace = /(\s){2,}/;
var startBlanks = range.sc.textContent.match(reStartBlanks)[0] || '';
var endBlanks = range.sc.textContent.match(reEndBlanks)[0] || '';
var trimmed = range.sc.textContent.replace(reStartBlanks, '').replace(reEndBlanks, '');
// Remove the single inner nbsp's and replace the multiple inner spaces with nbsp's
var cleanContents = trimmed.replace(reAllNBSP, ' ')
.replace(reMultipleSpace, function (space) {
return Array(space.length + 1).join('\u00A0');
});
// Keep the leading/trailing whitespace, nbsp's and zero-width chars
range.sc.textContent = startBlanks + cleanContents + endBlanks;
this._removeInvisibleChar(range);
range.select();
this.context.invoke('editor.saveRange');
},
/**
* Returns true if the node is a text node containing nothing
*
* @param {Node} node
* @returns {Boolean}
*/
isBlankText: function (node) {
return dom.isText(node) &&
this.getRegexBlank({
not: true,
notspace: true,
nbsp: true,
invisible: true,
})
.test(node.textContent);
},
/**
* Returns true if the node is blank.
* In this context, a blank node is understood as
* a node expecting text contents (or with children expecting text contents)
* but without any.
*
* @param {Node} node
* @returns {Boolean}
*/
isBlankNode: function (node) {
if (dom.isVoid(node) || dom.isIcon(node)) {
return false;
}
if (this.getRegexBlank({
space: true,
}).test(node[dom.isText(node) ? 'textContent' : 'innerHTML'])) {
return true;
}
if (node.childNodes.length && _.all(node.childNodes, this.isBlankNode.bind(this))) {
return true;
}
return false;
},
/**
* Returns true if the point is on the left/right edge of the first
* previous/next point with the given tag name (skips insignificant nodes).
*
* @param {Object} point
* @param {String} tagName
* @param {String('left'|'right')} side
* @returns {Boolean}
*/
isEdgeOfTag: function (point, tagName, side) {
var method = side === 'left' ? 'isLeftEdgePoint' : 'isRightEdgePoint';
var prevOrNext = side === 'left' ? 'prev' : 'next';
var newPt;
var first = true;
while (point && point.node.tagName !== tagName) {
newPt = this.skipNodes(point, prevOrNext, function (pt) {
return pt.node.tagName === tagName && dom[method](pt);
});
if (newPt.node.tagName === tagName || newPt.node.tagName === 'BR') {
point = newPt;
break;
}
if (newPt === point && (!first || dom.isText(point.node) && !dom[method](point))) {
break;
}
point = dom[prevOrNext + 'Point'](newPt);
first = false;
}
if (!point) {
return false;
}
var ancestor = dom.ancestor(point.node, function (n) {
return n.tagName === tagName;
});
return !!(ancestor && dom[method + 'Of'](point, ancestor));
},
/**
* Returns true if the node is a "format" node.
* In this context, a "format" node is understood as
* an editable block or an editable element expecting text
* (eg.: p, h1, span).
*
* @param {Node} node
* @returns {Boolean}
*/
isFormatNode: function (node) {
return node.tagName && this.options.styleTags.indexOf(node.tagName.toLowerCase()) !== -1;
},
/**
* Returns true if the node is within a table.
*
* @param {Node} node
* @returns {Boolean}
*/
isInTable: function (node) {
return !!dom.ancestor(node, function (n) {
return n.tagName === 'TABLE';
});
},
/**
* Returns true if the point is on the left edge of a block node
* (skips insignificant nodes).
*
* @param {Object} point
* @returns {Boolean}
*/
isLeftEdgeOfBlock: function (point) {
point = this.skipNodes(point, 'prev');
return dom.isLeftEdgePointOf(point, this.firstBlockAncestor(point.node));
},
/**
* Returns true if the point is on the left edge of the first
* previous point with the given tag name (skips insignificant nodes).
*
* @param {Object} point
* @param {String} tagName
* @returns {Boolean}
*/
isLeftEdgeOfTag: function (point, tagName) {
return this.isEdgeOfTag(point, tagName, 'left');
},
/**
* Returns true if the node is a block.
*
* @param {Node} node
* @returns {Boolean}
*/
isNodeBlockType: function (node) {
if (dom.isText(node)) {
return false;
}
var display = this.window.getComputedStyle(node).display;
// All inline elements have the word 'inline' in their display value, except 'contents'
return display.indexOf('inline') === -1 && display !== 'contents';
},
/**
* Returns true if the point is on the right edge of the first
* next point with the given tag name (skips insignificant nodes).
*
* @param {Object} point
* @param {String} tagName
* @returns {Boolean}
*/
isRightEdgeOfTag: function (point, tagName) {
return this.isEdgeOfTag(point, tagName, 'right');
},
/**
* Returns true if point should be ignored.
* This is generally used for trying to figure out if the point is an edge point.
*
* @param {Object} point
* @param {String} direction ('prev' or 'next')
* @param {Object} options
* @param {Boolean} options.noSkipBlankText true to not skip blank text
* @param {Boolean} options.noSkipSingleBRs true to not skip single BRs
* @param {Boolean} options.noSkipExtremeBreakableSpace true to not skip leading/trailing breakable space
* @param {Boolean} options.noSkipParent true to not skip to leaf nodes or offset 0
* @param {Boolean} options.noSkipSibling true to not skip if on edge and sibling is skippable
* @returns {Boolean}
*/
isSkippable: function (point, direction, options) {
options = options || {};
var isEdge = direction === 'prev' ? dom.isLeftEdgePoint(point) : dom.isRightEdgePoint(point);
// skip blank text nodes
if (
!options.noSkipBlankText &&
this.isBlankText(point.node)
) {
return true;
}
// skip single BRs
if (
!options.noSkipSingleBRs &&
point.node.tagName === 'BR' &&
(!point.node.previousSibling || this.isBlankText(point.node.previousSibling)) &&
(!point.node.nextSibling || this.isBlankText(point.node.nextSibling))
) {
return true;
}
// skip leading/trailing breakable space
if (
!options.noSkipExtremeBreakableSpace &&
(direction === 'prev' && !isEdge && point.offset <= this.countLeadingBreakableSpace(point.node) ||
direction === 'next' && point.offset > dom.nodeLength(point.node) - this.countTrailingBreakableSpace(point.node))
) {
return true;
}
// skip to leaf node or edge
var node = direction === 'prev' ? point.node.childNodes[0] : point.node.childNodes[point.node.childNodes.length - 1];
var offset = direction === 'prev' ? 0 : dom.nodeLength(node);
if (
!options.noSkipParent &&
!isEdge && point.node.childNodes.length &&
this.isSkippable(this.makePoint(node, offset), direction, options)
) {
return true;
}
// skip if on edge and sibling is skippable
var sibling = direction === 'prev' ? point.node.previousSibling : point.node.nextSibling;
offset = direction === 'prev' ? 0 : dom.nodeLength(sibling);
if (
!options.noSkipSibling &&
isEdge && sibling &&
this.isSkippable(this.makePoint(sibling, offset), direction, _.defaults({
noSkipSibling: true,
}, options))
) {
return true;
}
return false;
},
/**
* Returns true if the node is a text node with visible text.
*
* @param {Node} node
* @returns {Boolean}
*/
isVisibleText: function (node) {
return !node.tagName && this.getRegex('char').test(node.textContent);
},
/**
* Get the last leaf of a node, that is editable and not a media.
* In this context, a leaf node is understood as a childless node.
*
* @param {Node} node
* @returns {Node}
*/
lastLeaf: function (node) {
while (node.lastChild && !dom.isMedia(node) && this.options.isEditableNode(node)) {
node = node.lastChild;
}
return node;
},
/**
* Returns a Point object from a node and an offset.
*
* @param {Node} node
* @param {Number} offset
*/
makePoint: function (node, offset) {
return {
node: node,
offset: offset,
};
},
/**
* Merges mergeFromBlock into mergeIntoBlock, respecting the rules of unbreakable.
*
* @param {Node} mergeFromBlock block to merge from
* @param {Node} mergeIntoBlock block to merge into
* @returns {Object} {node, offset}
*/
mergeNonSimilarBlocks: function (mergeFromBlock, mergeIntoBlock) {
var point;
var mergeableTags = this.options.styleTags.join(', ') + ', li';
var $contents = $(mergeFromBlock).find('*').addBack()
.filter(mergeableTags)
.filter(function (i, n) {
if (!(n.tagName === 'LI' && $(n).find(mergeableTags).length)) {
return n;
}
}).contents();
var containsUnbreakables = !!$contents.filter(this.options.isUnbreakable).length;
if ($contents.length && !containsUnbreakables) {
if (dom.isText($contents[0])) {
this.removeExtremeBreakableSpace($contents[0]);
}
var $lastContents = $(mergeIntoBlock).contents().last();
if (!($contents.length === 1 && $contents[0].tagName === 'BR')) {
if (mergeIntoBlock.innerHTML.trim() === '
') {
$(mergeIntoBlock).contents().remove();
$(mergeIntoBlock).append($contents);
$lastContents = false;
} else {
$lastContents.after($contents);
}
}
while (mergeFromBlock.parentNode && this.isBlankNode(mergeFromBlock.parentNode)) {
mergeFromBlock = mergeFromBlock.parentNode;
}
$(mergeFromBlock).remove();
point = {};
if ($lastContents && $lastContents.length) {
point = this.makePoint($lastContents[0], dom.nodeLength($lastContents[0]));
} else {
point = this.makePoint($contents[0], 0);
}
point = this.deleteEdge(point.node, 'next', true) || point;
}
return point;
},
/**
* Normalize the DOM and range.
*/
normalize: function () {
this.editable.normalize();
var range = this.context.invoke('editor.createRange');
var rangeN = range.normalize();
// summernote's normalize function fails when br in text,
// and targets the br instead of the point just after br.
var point = rangeN.getStartPoint();
if (point.node.tagName === "BR") {
point = dom.nextPoint(point);
}
if (point.node.tagName && point.node.childNodes[point.offset]) {
point = dom.nextPoint(point);
}
if (point.node.tagName === "BR") {
point = dom.nextPoint(point);
}
if (point.node !== range.sc || point.offset !== range.so) {
range = this.context.invoke('editor.setRange', point.node, point.offset);
range.select();
}
},
/**
* Simulate a do_notify by notifying the user through a dialog.
*
* @param {String} title
* @param {String} content
*/
notify: function (title, content) {
var $notif = $('
' + content + '
'); new Dialog(this, { title: title, size: 'medium', $content: $notif, }).open(); }, /** * Return true if the `container` contains the `contained` and only * the `contained` (blank text nodes are ignored). * * @param {Node} container * @param {Node} contained * @returns {Boolean} */ onlyContains: function (container, contained) { var self = this; if (!$.contains(container, contained)) { return false; } var $contents = $(container).contents(); var otherContents = $contents.filter(function (index, node) { if (node === contained || dom.isText(node) && !self.isVisibleText(node)) { return false; } return true; }); return !otherContents.length; }, /** * Reorders the classes in the node's class attribute and returns it. * * @param {Node} node * @returns {String} */ orderClass: function (node) { var className = node.getAttribute && node.getAttribute('class'); if (!className) { return null; } className = className.replace(this.getRegex('spaceOrNewline', 'g'), ' ') .replace(this.getRegex('startAndEndSpace', 'g'), '') .replace(this.getRegex('space', 'g'), ' '); className = className.replace('o_default_snippet_text', '') .replace('o_checked', ''); if (!className.length) { node.removeAttribute("class"); return null; } className = className.split(" "); className.sort(); className = className.join(" "); node.setAttribute('class', className); return className; }, /** * Reorders the styles in the node's style attributes and returns it. * * @param {Node} node * @returns {String} */ orderStyle: function (node) { var style = node.getAttribute('style'); if (!style) { return null; } style = style.replace(this.getRegex('spaceOrNewline'), ' ') .replace(this.getRegex('startAndEndSemicolon', 'g'), '') .replace(this.getRegex('semicolon', 'g'), ';'); if (!style.length) { node.removeAttribute("style"); return null; } style = style.split(";"); style.sort(); style = style.join("; ") + ";"; node.setAttribute('style', style); return style; }, /** * Returns the path from the editable node to the given node. * * @param {Node} node * @returns {Object[]} list of objects (tagName, offset) */ path: function (node) { var path = []; while (node && node !== this.editable) { var tagName = node.tagName; path.unshift({ tagName: tagName, offset: _.filter(node.parentNode.childNodes, function (node) { return node.tagName === tagName; }).indexOf(node), }); node = node.parentNode; } return path; }, /** * Removes all attributes without a value from the given node. * * @param {Node} node * @returns {Node} */ removeBlankAttrs: function (node) { _.each([].slice.call(node.attributes), function (attr) { if (!attr.value) { node.removeAttribute(attr.name); } }); return node; }, /** * Remove a node's direct blank siblings, if any. * Eg: TextNodeText => TextNodeText * * @param {Node} node */ removeBlankSiblings: function (node) { var isAfterBlank = node.previousSibling && this.isBlankNode(node.previousSibling); if (isAfterBlank) { $(node.previousSibling).remove(); } var isBeforeBlank = node.nextSibling && this.isBlankNode(node.nextSibling) if (isBeforeBlank) { $(node.nextSibling).remove(); } }, /** * Removes the block target and joins its siblings. * * @param {Node} target * @param {Boolean} doNotInsertP true to NOT fill an empty unbreakable with a p element. * @returns {Object} {node, offset} */ removeBlockNode: function (target, doNotInsertP) { var self = this; var check = function (point) { if (point.node === target) { return false; } return !point.node || self.options.isEditableNode(point.node) && (point.node.tagName === "BR" || self.isVisibleText(point.node)); }; var parent = target.parentNode; var offset = [].indexOf.call(parent.childNodes, target); var deleteEdge = 'next'; var point = dom.prevPointUntil(this.makePoint(target, 0), check); if (!point || !point.node) { deleteEdge = 'prev'; point = dom.nextPointUntil(this.makePoint(target, 0), check); } $(target).remove(); if ( point && (deleteEdge === 'prev' && point.offset) || deleteEdge === 'next' && point.offset === dom.nodeLength(point.node) ) { point = this.deleteEdge(point.node, deleteEdge) || point; } $(parent).contents().filter(function () { return dom.isText(this) && self.getRegexBlank({ atLeastOne: true, invisible: true, }).test(this.textContent); }).remove(); var br; if (parent.innerHTML === '') { br = this.document.createElement('br'); $(parent).append(br); point = this.makePoint(parent, 0); } if (!doNotInsertP && this.getRegexBlank({ space: true, invisible: true, }).test(parent.innerHTML)) { br = this.document.createElement('br'); if (this.options.isUnbreakableNode(parent) && parent.tagName !== "TD") { var p = this.document.createElement('p'); $(p).append(br); $(parent).append(p); } else { $(parent).append(br); } point = this.makePoint(br.parentNode, 0); } if (point && point.node.tagName === "BR" && point.node.parentNode) { point = { node: point.node.parentNode, offset: [].indexOf.call(point.node.parentNode.childNodes, point.node), }; } return point || this.makePoint(parent, offset); }, /** * Removes the empty inline nodes around the point, and joins its siblings. * * @param {Object} point {node, offset} * @returns {Object} {node, offset} */ removeEmptyInlineNodes: function (point) { var node = point.node; if (!point.node.tagName && !point.node.textContent.length) { node = node.parentNode; if ($(node).hasClass('o_default_snippet_text')) { // for default snippet value return point; } } var prev; var next; while ( node.tagName !== 'BR' && (node.tagName ? node.innerHTML : node.textContent) === '' && !this.isNodeBlockType(node) && this.options.isEditableNode(node.parentNode) && (!node.attributes || !node.attributes.contenteditable) && !dom.isMedia(node) ) { prev = node.previousSibling; next = node.nextSibling; point = { node: node.parentNode, offset: [].indexOf.call(node.parentNode.childNodes, node) }; $(node).remove(); node = point.node; } if (next && !next.tagName) { if (/^\s+[^\s<]/.test(next.textContent)) { next.textContent = next.textContent.replace(this.getRegex('startSpace'), '\u00A0'); } } if (prev) { if (!prev.tagName) { if (/[^\s>]\s+$/.test(prev.textContent)) { prev.textContent = prev.textContent.replace(this.getRegex('endSpace'), ' '); } } point = this.makePoint(prev, dom.nodeLength(prev)); } return point; }, /** * Removes any amount of leading/trailing breakable space from a text node. * Returns how many characters were removed at the start * and at the end of the text node. * * @param {Node} textNode * @param {Boolean} secureExtremeties (defaults to true) * @returns {Object} removed {start, end} */ removeExtremeBreakableSpace: function (textNode, secureExtremeties) { if (arguments.length === 1) { secureExtremeties = true; } if (secureExtremeties) { this.secureExtremeSingleSpace(textNode); } var removed = { start: 0, end: 0, }; textNode.textContent = textNode.textContent.replace(this.getRegex('startNotChar'), function (toRemove) { removed.start = toRemove.length; return ''; }); textNode.textContent = textNode.textContent.replace(this.getRegex('endNotChar'), function (toRemove) { removed.end = toRemove.length; return ''; }); return removed; }, /** * Makes the leading/trailing single space of a node non breakable (nbsp). * * @param {Node} node */ secureExtremeSingleSpace: function (node) { if (this.getRegex('endSingleSpace').test(node.textContent)) { // if the text ends with a single space, make it insecable node.textContent = node.textContent.substr(0, node.textContent.length - 1) + '\u00A0'; } if (this.getRegex('startSingleSpace').test(node.textContent)) { // if the text starts with a single space, make it insecable node.textContent = '\u00A0' + node.textContent.substr(1, node.textContent.length); } }, /** * Skips points to ignore (generally for trying to figure out if edge point). * Returns the resulting point. * * @param {Object} point * @param {String} direction ('prev' or 'next') * @param {function} pred (extra condition to stop at) * @param {Object} options * @param {Boolean} options.noSkipBlankText true to not skip blank text * @param {Boolean} options.noSkipSingleBRs true to not skip single BRs * @param {Boolean} options.noSkipExtremeBreakableSpace true to not skip leading/trailing breakable space * @param {Boolean} options.noSkipParent true to not skip to leaf nodes or offset 0 * @param {Boolean} options.noSkipSibling true to not skip if on edge and sibling is skippable * @returns {Object} {node, offset} */ skipNodes: function (point, direction, pred, options) { var self = this; if (arguments.length === 3 && !_.isFunction(arguments[2])) { // allow for passing options and no pred function options = _.clone(pred); pred = null; } options = options || {}; return dom[direction + 'PointUntil'](point, function (pt) { return !self.isSkippable(pt, direction, options) || pred && pred(pt); }); }, /** * Split the DOM tree at the node's start and end points. * * @param {Node} node */ splitAtNodeEnds: function (node) { if (!node.parentNode) { return; } var startPoint = this.makePoint(node, 0); var endPoint = this.makePoint(node, dom.nodeLength(node)); var splitOptions = { isSkipPaddingBlankHTML: true, }; this.splitTree(node.parentNode, startPoint, splitOptions); this.splitTree(node.parentNode, endPoint, splitOptions); // Splitting at ends may create blank nodes (because of dom.splitTree) so let's clean it up: this.removeBlankSiblings(node.parentNode); }, /** * Split the DOM tree at the point * * @param {Node} root - split root * @param {BoundaryPoint} point {node, offset} * @param {Object} [options] * @param {Boolean} [options.nextText] - default: false * @param {Boolean} [options.isSkipPaddingBlankHTML] - default: false * @param {Boolean} [options.isNotSplitEdgePoint] - default: false * @returns {Node} right node of boundary point */ splitTree: function (root, point, options) { var nextText; if (options && options.nextText && !point.node.tagName) { nextText = point.node.splitText(point.offset); } var emptyText = false; if (!point.node.tagName && point.node.textContent === "") { emptyText = true; point.node.textContent = '\u200B'; point.offset = 1; } var next = dom.splitTree(root, point, options); if (emptyText) { point.node.textContent = ''; } var result = nextText || next || point.node; var att = nextText ? 'textContent' : 'innerHTML'; if (/^\s+([^\s<])/.test(result[att])) { result[att] = result[att].replace(this.getRegex('startSpace'), '\u00A0'); } return result; }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- _insertTextNodeInEditableArea: function (range, text) { // try to insert the text node in editable area var textNode = this.document.createTextNode(text); if (this.options.isEditableNode(range.sc) && $(range.sc).closest('[contenteditable]').attr('contenteditable') === 'true') { if (dom.isText(range.sc) && this.isVisibleText(range.sc)) { // If range is on visible text: split the text at offset and insert the text node range.sc.splitText(range.so); range.so = range.eo = dom.nodeLength(range.sc); $(range.sc).after(textNode); } else if (dom.isBR(range.sc)) { if (range.sc.nextSibling && this.isVisibleText(range.sc.nextSibling)) { $(range.sc).before(textNode); } else { $(range.sc).replaceWith(textNode); } } else if (dom.isVoid(range.sc)) { $(range.sc).before(textNode); } else if (range.sc.childNodes[range.so]) { var node = range.sc.childNodes[range.so]; if (dom.isBR(node)) { $(node).replaceWith(textNode); } else { $(node).before(textNode); } } else if (dom.isText(range.sc)) { $(range.sc).after(textNode); } else if (this.options.isUnbreakableNode(range.sc)) { $(range.sc).append(textNode); } } if (!textNode.parentNode && this.options.isEditableNode(range.sc.parentNode)) { $(range.sc).before(textNode); } return textNode.parentNode && textNode; }, /** * Perform operations that are necessary after the insertion of a visible character: * adapt range for the presence of zero-width characters, move out of media, rerange. * * @private */ _removeInvisibleChar: function (range) { if (range.sc.tagName || dom.ancestor(range.sc, dom.isAnchor)) { return true; } var needReselect = false; var fake = range.sc.parentNode; if ((fake.className || '').indexOf('o_fake_editable') !== -1 && dom.isMedia(fake)) { var $media = $(fake.parentNode); $media[fake.previousElementSibling ? 'after' : 'before'](fake.firstChild); needReselect = true; } if (range.sc.textContent.slice(range.so - 2, range.so - 1) === '\u200B') { range.sc.textContent = range.sc.textContent.slice(0, range.so - 2) + range.sc.textContent.slice(range.so - 1); range.so = range.eo = range.so - 1; needReselect = true; } if (range.sc.textContent.slice(range.so, range.so + 1) === '\u200B') { range.sc.textContent = range.sc.textContent.slice(0, range.so) + range.sc.textContent.slice(range.so + 1); needReselect = true; } if (needReselect) { range = range.normalize(); } return range; }, _standardizeRangeOnEdge: function (range) { var self = this; var invisible = this.document.createTextNode('\u200B'); if (dom.isText(range.sc) && !this.isVisibleText(range.sc) && range.sc.nextSibling) { var firstLeafOfNext = this.firstLeaf(range.sc.nextSibling); range = this.context.invoke('editor.setRange', firstLeafOfNext, 0); } // Create empty text node to have a range into the node if (range.sc.tagName && !dom.isVoid(range.sc) && !range.sc.childNodes[range.so]) { $(range.sc).append(invisible); range = this.context.invoke('editor.setRange', invisible, 0); } // On left edge of non-empty element: move before var siblings = range.sc.parentNode && range.sc.parentNode.childNodes; var isInEmptyElem = !siblings || !siblings.length || _.all(siblings, this.isBlankNode.bind(this)); if ( !range.so && !isInEmptyElem && !(range.sc.previousSibling && range.sc.previousSibling.tagName === "BR") && !this.options.isEditableNode(range.sc) ) { var point = range.getStartPoint(); var newPoint = dom.prevPointUntil(point, function (pt) { return pt.node !== range.sc && !pt.node.tagName && !self.isBlankText(pt.node); }); if (!newPoint || this.firstBlockAncestor(newPoint.node) !== this.firstBlockAncestor(point.node)) { range = this.context.invoke('editor.setRange', point.node, point.offset); } else { range = this.context.invoke('editor.setRange', newPoint.node, newPoint.offset); } } return range; }, _wrapTextWithP: function (textNode) { if (dom.ancestor(textNode, dom.isAnchor)) { return; } var self = this; var isFormatNode = dom.ancestor(textNode, this.isFormatNode.bind(this)); if (!isFormatNode) { var hasInlineParent = dom.ancestor(textNode.parentNode, function (node) { return !self.isNodeBlockType(node); }); if ( !hasInlineParent && (textNode.tagName || !(textNode.previousSibling && this.isVisibleText(textNode.previousSibling) || textNode.nextSibling && this.isVisibleText(textNode.nextSibling)) ) ) { var blankP = this.document.createElement('p'); $(textNode).after(blankP); $(blankP).prepend(textNode); } } }, }); registry.add('HelperPlugin', HelperPlugin); return HelperPlugin; });