odoo.define('web_editor.wysiwyg.plugin.keyboard', 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 keycode = ({
'8': 'BACKSPACE',
'9': 'TAB',
'13': 'ENTER',
'46': 'DELETE',
});
var isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
var iPhone = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
var KeyboardPlugin = AbstractPlugin.extend({
events: iPhone ?
{
'DOMNodeInserted .note-editable': '_removeGarbageSpans',
} :
{
'summernote.keydown': '_onKeydown',
'DOMNodeInserted .note-editable': '_removeGarbageSpans',
'keypress .note-editable': '_onKeyPress',
'textInput .note-editable': '_onTextInput',
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Perform various DOM and range manipulations after a deletion:
* - Rerange out of BR elements
* - Clean the DOM at current range position
*
* @see _handleDeletion
*
* @private
* @param {Object} range
* @param {String('prev'|'next')} direction 'prev' to delete BEFORE the carret
* @returns {Object} range
*/
_afterDeletion: function (range, direction) {
range = direction === 'prev' ? this._insertInvisibleCharAfterSingleBR(range) : range;
range = this._rerangeOutOfBR(range, direction);
range = this._cleanRangeAfterDeletion(range);
return range;
},
/**
* 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
*/
_afterVisibleChar: function () {
var range = this.context.invoke('editor.createRange');
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.normalize().select();
}
},
/**
* Perform various DOM and range manipulations to prepare a deletion:
* - Rerange within the element targeted by the range
* - Slice the text content if necessary
* - Move before an invisible BR if necessary
* - Replace a media with an empty SPAN if necessary
* - Change the direction of deletion if necessary
* - Clean the DOM at range position if necessary
*
* @see _handleDeletion
*
* @private
* @param {Object} range
* @param {String('prev'|'next')} direction
* @param {Boolean} didDeleteNodes true if nodes were already deleted prior to this call
* @returns {Object} {didDeleteNodes: Boolean, range: Object, direction: String('prev'|next')}
*/
_beforeDeletion: function (range, direction, didDeleteNodes) {
var res = {
range: range,
direction: direction,
didDeleteNodes: didDeleteNodes,
};
res.range = this._rerangeToOffsetChild(res.range, direction);
res.range = this._sliceAndRerangeBeforeDeletion(res.range);
res.range = direction === 'prev' ? this._moveBeforeInvisibleBR(res.range) : res.range;
if (dom.isMedia(res.range.sc)) {
var span = this._replaceMediaWithEmptySpan(res.range.sc);
res.range = this.context.invoke('editor.setRange', span, 0);
res.didDeleteNodes = true;
return res;
}
if (res.didDeleteNodes) {
res.direction = 'next';
return res;
}
res.range = this._cleanRangeBeforeDeletion(res.range, direction);
return res;
},
/**
* Clean the DOM at range position after a deletion:
* - Remove empty inline nodes
* - Fill the current node if it's empty
*
* @private
* @param {Object} range
* @returns {Object} range
*/
_cleanRangeAfterDeletion: function (range) {
var point = range.getStartPoint();
point = this.context.invoke('HelperPlugin.removeEmptyInlineNodes', point);
point = this.context.invoke('HelperPlugin.fillEmptyNode', point);
range = this.context.invoke('editor.setRange', point.node, point.offset);
return range;
},
/**
* Clean the DOM at range position:
* - Remove all previous zero-width characters
* - Remove leading/trailing breakable space
*
* @private
* @param {Object} range
* @param {String('prev'|'next')} direction
* @returns {Object} range
*/
_cleanRangeBeforeDeletion: function (range, direction) {
if (direction === 'prev') {
this._removeAllPreviousInvisibleChars(range);
}
range = this._removeExtremeBreakableSpaceAndRerange(range);
return range;
},
/**
* Get information on the range in order to perform a deletion:
* - The point at which to delete, if any
* - Whether the node contains a block
* - The block to remove, if any
*
* @private
* @param {Object} range
* @param {String('prev'|'next')} direction
* @param {Boolean} wasOnStartOfBR true if the requested deletion started at
* the beginning of a BR element
* @returns {Object} {
* point: {false|Object},
* hasBlock: {Boolean},
* blockToRemove: {false|Node},
* }
*/
_getDeleteInfo: function (range, direction, wasOnStartOfBR) {
var self = this;
var hasBlock = false;
var blockToRemove = false;
var method = direction === 'prev' ? 'prevPointUntil' : 'nextPointUntil';
var pt = range.getStartPoint();
pt = dom[method](pt, function (point) {
var isAtStartOfMedia = !point.offset && dom.isMedia(point.node);
var isBRorHR = point.node.tagName === 'BR' || point.node.tagName === 'HR';
var isRootBR = wasOnStartOfBR && point.node === range.sc;
var isOnRange = range.ec === point.node && range.eo === point.offset;
if (!point.offset && self.context.invoke('HelperPlugin.isNodeBlockType', point.node)) {
hasBlock = true;
if (blockToRemove) {
return true;
}
}
if (!blockToRemove && (isAtStartOfMedia || isBRorHR && !isRootBR)) {
blockToRemove = point.node;
return false;
}
if (isOnRange) {
return false;
}
return self._isDeletableNode(point.node);
});
return {
point: pt || false,
hasBlock: hasBlock,
blockToRemove: blockToRemove,
};
},
/**
* Handle deletion (BACKSPACE / DELETE).
*
* @private
* @param {String('prev'|'next')} direction 'prev' to delete BEFORE the carret
* @returns {Boolean} true if case handled
*/
_handleDeletion: function (direction) {
var range = this.context.invoke('editor.createRange');
range = direction === 'prev' && this._replaceEmptyParentWithEmptyP(range);
if (range) {
range = this._afterDeletion(range, direction);
range.select();
return true;
}
var didDeleteNodes = this.context.invoke('HelperPlugin.deleteSelection');
range = this.context.invoke('editor.createRange');
var wasOnStartOfBR = direction === 'prev' && !range.so && range.sc.tagName === 'BR';
this._removeNextEmptyUnbreakable(range.sc);
var temp = this._beforeDeletion(range, direction, didDeleteNodes);
didDeleteNodes = temp.didDeleteNodes;
range = temp.range;
direction = temp.direction;
if (!didDeleteNodes) {
var newRange = this._performDeletion(range, direction, wasOnStartOfBR);
didDeleteNodes = newRange.so !== range.so || newRange.sc !== range.sc;
range = newRange;
}
range = this._afterDeletion(range, direction);
range = range.collapse(direction === 'prev').select();
this.editable.normalize();
return didDeleteNodes;
},
/**
* Handle ENTER.
*
* @private
* @returns {Boolean} true if case handled
*/
_handleEnter: function () {
var self = this;
var range = this.context.invoke('editor.createRange');
var ancestor = dom.ancestor(range.sc, function (node) {
return dom.isLi(node) || self.options.isUnbreakableNode(node.parentNode) && node.parentNode !== self.editable ||
self.context.invoke('HelperPlugin.isNodeBlockType', node) && !dom.ancestor(node, dom.isLi);
});
if (
dom.isLi(ancestor) && !$(ancestor.parentNode).hasClass('list-group') &&
this.context.invoke('HelperPlugin.getRegexBlank', {
space: true,
newline: true,
}).test(ancestor.textContent) &&
$(ancestor).find('br').length <= 1 &&
!$(ancestor).find('.fa, img').length
) {
// double enter in a list make oudent
this.context.invoke('BulletPlugin.outdent');
return true;
}
var btn = dom.ancestor(range.sc, function (n) {
return $(n).hasClass('btn');
});
var point = range.getStartPoint();
if (!point.node.tagName && this.options.isUnbreakableNode(point.node.parentNode)) {
return this._handleShiftEnter();
}
if (point.node.tagName && point.node.childNodes[point.offset] && point.node.childNodes[point.offset].tagName === "BR") {
point = dom.nextPoint(point);
}
if (point.node.tagName === "BR") {
point = dom.nextPoint(point);
}
var next = this.context.invoke('HelperPlugin.splitTree', ancestor, point, {
isSkipPaddingBlankHTML: !this.context.invoke('HelperPlugin.isNodeBlockType', point.node.parentNode) && !!point.node.parentNode.nextSibling
});
$(next).removeClass('o_checked');
while (next.firstChild) {
next = next.firstChild;
}
// if there is no block in the split parents, then we add a br between the two node
var hasSplitBlock = false;
var node = next;
var lastChecked = node;
while (node && node !== ancestor && node !== this.editable) {
if (this.context.invoke('HelperPlugin.isNodeBlockType', node)) {
hasSplitBlock = true;
break;
}
lastChecked = node;
node = node.parentNode;
}
if (!hasSplitBlock && lastChecked.tagName) {
$(lastChecked).before(this.document.createElement('br'));
}
if (!next.tagName) {
this.context.invoke('HelperPlugin.secureExtremeSingleSpace', next);
}
if (next.tagName !== "BR" && next.innerHTML === "") {
next.innerHTML = '\u200B';
}
if (ancestor) {
var firstChild = this.context.invoke('HelperPlugin.firstLeaf', ancestor);
var lastChild = this.context.invoke('HelperPlugin.lastLeaf', ancestor);
if (this.context.invoke('HelperPlugin.isBlankNode', ancestor)) {
firstChild = dom.isText(firstChild) ? firstChild.parentNode : firstChild;
$(firstChild).contents().remove();
$(firstChild).append(this.document.createElement('br'));
}
if (lastChild.tagName === 'BR' && lastChild.previousSibling) {
$(lastChild).after(this.document.createTextNode('\u200B'));
}
}
// move to next editable area
point = this.context.invoke('HelperPlugin.makePoint', next, 0);
if (
(point.node.tagName && point.node.tagName !== 'BR') ||
!this.context.invoke('HelperPlugin.isVisibleText', point.node.textContent)
) {
point = dom.nextPointUntil(point, function (pt) {
if (pt.node === point.node) {
return;
}
return (
pt.node.tagName === "BR" ||
self.context.invoke('HelperPlugin.isVisibleText', pt.node)
) &&
self.options.isEditableNode(pt.node);
});
point = point || this.context.invoke('HelperPlugin.makePoint', next, 0);
if (point.node.tagName === "BR") {
point = dom.nextPoint(point);
}
}
if (!hasSplitBlock && !point.node.tagName) {
point.node.textContent = '\u200B' + point.node.textContent;
point.offset = 1;
}
// if the left part of the split node ends with a space, replace that space with nbsp
if (range.sc.textContent) {
var endSpace = this.context.invoke('HelperPlugin.getRegex', 'endSpace');
range.sc.textContent = range.sc.textContent.replace(endSpace,
function (trailingSpaces) {
return Array(trailingSpaces.length + 1).join('\u00A0');
}
);
}
// On buttons, we want to split the button and move to the beginning of it
if (btn) {
next = dom.ancestor(point.node, function (n) {
return $(n).hasClass('btn');
});
// Force content in empty buttons, the carret can be moved there
this.context.invoke('LinkPopover.hide');
this.context.invoke('LinkPopover.fillEmptyLink', next, true);
this.context.invoke('LinkPopover.fillEmptyLink', btn, true);
// Move carret to the new button
range = this.context.invoke('editor.setRange', next.firstChild, 0);
range.select();
} else {
range = this.context.invoke('editor.setRange', point.node, point.offset);
range.normalize().select();
}
return true;
},
/**
* Handle SHIFT+ENTER.
*
* @private
* @returns {Boolean} true if case handled
*/
_handleShiftEnter: function () {
var range = this.context.invoke('editor.createRange');
var target = range.sc.childNodes[range.so] || range.sc;
var before;
if (target.tagName) {
if (target.tagName === "BR") {
before = target;
} else if (target === range.sc) {
if (range.so) {
before = range.sc.childNodes[range.so - 1];
} else {
before = this.document.createTextNode('');
$(range.sc).append(before);
}
}
} else {
before = target;
var after = target.splitText(target === range.sc ? range.so : 0);
if (
!after.nextSibling && after.textContent === '' &&
this.context.invoke('HelperPlugin.isNodeBlockType', after.parentNode)
) {
after.textContent = '\u200B';
}
if (!after.tagName && (!after.previousSibling || after.previousSibling.tagName === "BR")) {
after.textContent = after.textContent.replace(startSpace, '\u00A0');
}
}
if (!before) {
return true;
}
var br = this.document.createElement('br');
$(before).after(br);
var next = this.context.invoke('HelperPlugin.makePoint', br, 0);
var startSpace = this.context.invoke('HelperPlugin.getRegex', 'startSpace');
if (!before.tagName) {
next = dom.nextPoint(next);
var nextNode = this.context.invoke('HelperPlugin.firstLeaf', next.node.childNodes[next.offset] || next.node);
if (!nextNode.tagName) {
next.node = nextNode;
next.offset = 0;
}
}
if (
next.node.tagName === "BR" && next.node.nextSibling &&
!next.node.nextSibling.tagName && !dom.ancestor(next.node, dom.isPre)
) {
next.node.nextSibling.textContent = next.node.nextSibling.textContent.replace(startSpace, '\u00A0');
}
if (
!next.node.tagName &&
(!next.node.previousSibling || next.node.previousSibling.tagName === "BR") &&
!dom.ancestor(next.node, dom.isPre)
) {
next.node.textContent = next.node.textContent.replace(startSpace, '\u00A0');
}
range = this.context.invoke('editor.setRange', next.node, next.offset);
range.select();
return true;
},
/**
* Insert a zero-width character after a BR if the range is
* at the beginning of an invisible text node
* and after said single BR element.
*
* @private
* @param {Object} range
* @returns {Object} range
*/
_insertInvisibleCharAfterSingleBR: function (range) {
if (this._isAtStartOfInvisibleText(range) && this._isAfterSingleBR(range.sc)) {
var invisibleChar = this.document.createTextNode('\u200B');
$(range.sc.previousSibling).after(invisibleChar);
range = this.context.invoke('editor.setRange', invisibleChar, 1);
}
return range;
},
/**
* Return true if the node comes after a BR element.
*
* @private
* @param {Node} node
* @returns {Boolean}
*/
_isAfterBR: function (node) {
return node.previousSibling && node.previousSibling.tagName === 'BR';
},
/**
* Return true if the range if positioned after a BR element that doesn't visually
* show a new line in the DOM: a BR in an element that has only a BR, or text then a BR.
* eg:
or text
*
* @private
* @param {Object} range
* @returns {Boolean}
*/
_isAfterInvisibleBR: function (range) {
return this._isAfterOnlyBR(range) || this._isAfterOnlyTextThenBR(range);
},
/**
* Return true if the range is positioned on a text node, after an zero-width character.
*
* @private
* @param {Object} range
* @returns {Boolean}
*/
_isAfterInvisibleChar: function (range) {
return !range.sc.tagName && range.so && range.sc.textContent[range.so - 1] === '\u200B';
},
/**
* Return true if the range is positioned on a text node, after an leading zero-width character.
*
* @private
* @param {Object} range
* @returns {Boolean}
*/
_isAfterLeadingInvisibleChar: function (range) {
return !range.sc.tagName && range.so === 1 && range.sc.textContent[0] === '\u200B';
},
/**
* Return true if the range if positioned after a BR element in a node that has only a BR.
* eg:
*
* @private
* @param {Object} range
* @returns {Boolean}
*/
_isAfterOnlyBR: function (range) {
return this._hasOnlyBR(range.sc) && range.so === 1;
},
/**
* Return true if the node has for only element child a BR element.
*
* @private
* @param {Node} node
* @returns {Boolean}
*/
_hasOnlyBR: function (node) {
return node.childElementCount === 1 && node.firstChild.tagName === 'BR';
},
/**
* Return true if the range if positioned after a BR element in a node that has only text
* and ends with a BR.
* eg: text
*
* @private
* @param {Object} range
* @returns {Boolean}
*/
_isAfterOnlyTextThenBR: function (range) {
var hasTrailingBR = range.sc.lastChild && range.sc.lastChild.tagName === 'BR';
if (!hasTrailingBR) {
return false;
}
var hasOnlyTextThenBR = _.all(range.sc.childNodes, function (n) {
return dom.isText(n) || n === range.sc.lastChild;
});
var isAfterTrailingBR = range.so === dom.nodeLength(range.sc);
return hasOnlyTextThenBR && isAfterTrailingBR;
},
/**
* Return true if the node is after a single BR.
*
* @private
* @param {Node} node
* @returns {Boolean}
*/
_isAfterSingleBR: function (node) {
var isPreviousAfterBR = node.previousSibling && this._isAfterBR(node.previousSibling);
return this._isAfterBR(node) && !isPreviousAfterBR;
},
/**
* Return true if the node comes after two BR elements.
*
* @private
* @param {Node} node
* @returns {Boolean}
*/
_isAfterTwoBRs: function (node) {
var isAfterBR = this._isAfterBR(node);
var isPreviousSiblingAfterBR = node.previousSibling && this._isAfterBR(node.previousSibling);
return isAfterBR && isPreviousSiblingAfterBR;
},
/**
* Return true if the range is positioned at the start of an invisible text node.
*
* @private
* @param {Object} range
* @returns {Boolean}
*/
_isAtStartOfInvisibleText: function (range) {
return !range.so && dom.isText(range.sc) && !this.context.invoke('HelperPlugin.isVisibleText', range.sc);
},
/**
* Return true if the range is positioned on a text node, before a trailing zero-width character.
*
* @private
* @param {Object} range
* @returns {Boolean}
*/
_isBeforeTrailingInvisibleChar: function (range) {
var isBeforeLastCharOfText = !range.sc.tagName && range.so === dom.nodeLength(range.sc) - 1;
var isLastCharInvisible = range.sc.textContent.slice(range.so) === '\u200B';
return isBeforeLastCharOfText && isLastCharInvisible;
},
/**
* Return true if the node is deletable.
*
* @private
* @param {Node} node
* @return {Boolean}
*/
_isDeletableNode: function (node) {
var isVisibleText = this.context.invoke('HelperPlugin.isVisibleText', node);
var isMedia = dom.isMedia(node);
var isBR = node.tagName === 'BR';
var isEditable = this.options.isEditableNode(node);
return isEditable && (isVisibleText || isMedia || isBR);
},
/**
* Return true if the range is positioned on an edge to delete, depending on the given direction.
*
* @private
* @param {Object} range
* @param {String('prev'|'next')} direction
*/
_isOnEdgeToDelete: function (range, direction) {
var isOnBR = range.sc.tagName === 'BR';
var parentHasOnlyBR = range.sc.parentNode && range.sc.parentNode.innerHTML.trim() === "
";
var isOnDirEdge;
if (direction === 'next') {
isOnDirEdge = range.so === dom.nodeLength(range.sc);
} else {
isOnDirEdge = range.so === 0;
}
return (!isOnBR || parentHasOnlyBR) && isOnDirEdge;
},
/**
* Move the range before a BR if that BR doesn't visually show a new line in the DOM.
* Return the new range.
*
* @private
* @param {Object} range
* @returns {Object} range
*/
_moveBeforeInvisibleBR: function (range) {
if (this._isAfterInvisibleBR(range)) {
range.so -= 1;
}
return range;
},
/**
* Perform a deletion in the given direction.
* Note: This is where the actual deletion takes place.
* It should be preceded by _beforeDeletion and
* followed by _afterDeletion.
*
* @see _handleDeletion
*
* @private
* @param {Object} range
* @param {String('prev'|'next')} direction 'prev' to delete BEFORE the carret
* @param {Boolean} wasOnStartOfBR true if the requested deletion started at
* the beginning of a BR element
* @returns {Object} range
*/
_performDeletion: function (range, direction, wasOnStartOfBR) {
var didDeleteNodes = false;
if (this._isOnEdgeToDelete(range, direction)) {
var rest = this.context.invoke('HelperPlugin.deleteEdge', range.sc, direction);
didDeleteNodes = !!rest;
if (didDeleteNodes) {
range = this.context.invoke('editor.setRange', rest.node, rest.offset);
return range;
}
}
var deleteInfo = this._getDeleteInfo(range, direction, wasOnStartOfBR);
if (!deleteInfo.point) {
return range;
}
var point = deleteInfo.point;
var blockToRemove = deleteInfo.blockToRemove;
var hasBlock = deleteInfo.hasBlock;
var isLonelyBR = blockToRemove && blockToRemove.tagName === 'BR' && this._hasOnlyBR(blockToRemove.parentNode);
var isHR = blockToRemove && blockToRemove.tagName === "HR";
if (blockToRemove && !isLonelyBR) {
$(blockToRemove).remove();
point = isHR ? this.context.invoke('HelperPlugin.deleteEdge', range.sc, direction) : point;
didDeleteNodes = true;
} else if (!hasBlock) {
var isAtEndOfNode = point.offset === dom.nodeLength(point.node);
var shouldMove = isAtEndOfNode || direction === 'next' && point.offset;
point.offset = shouldMove ? point.offset - 1 : point.offset;
point.node = this._removeCharAtOffset(point);
didDeleteNodes = true;
var isInPre = !!dom.ancestor(range.sc, dom.isPre);
if (!isInPre) {
this.context.invoke('HelperPlugin.secureExtremeSingleSpace', point.node);
}
if (direction === 'prev' && !point.offset && !this._isAfterBR(point.node)) {
point.node = this._replaceLeadingSpaceWithSingleNBSP(point.node);
}
}
if (didDeleteNodes) {
range = this.context.invoke('editor.setRange', point.node, point.offset);
}
return range;
},
/**
* Prevent the appearance of a text node with the editable DIV as direct parent:
* wrap it in a p element.
*
* @private
*/
_preventTextInEditableDiv: function () {
var range = this.context.invoke('editor.createRange');
while (
dom.isText(this.editable.firstChild) &&
!this.context.invoke('HelperPlugin.isVisibleText', this.editable.firstChild)
) {
var node = this.editable.firstChild;
if (node && node.parentNode) {
node.parentNode.removeChild(node);
}
}
var editableIsEmpty = !this.editable.childNodes.length;
if (editableIsEmpty) {
var p = this.document.createElement('p');
p.innerHTML = '
';
this.editable.appendChild(p);
range = this.context.invoke('editor.setRange', p, 0);
} else if (this.context.invoke('HelperPlugin.isBlankNode', this.editable.firstChild) && !range.sc.parentNode) {
this.editable.firstChild.innerHTML = '
';
range = this.context.invoke('editor.setRange', this.editable.firstChild, 0);
}
range.select();
},
/**
* Remove all invisible chars before the current range, that are adjacent to it,
* then rerange.
*
* @private
* @param {Object} range
* @returns {Object} range
*/
_removeAllPreviousInvisibleChars: function (range) {
while (this._isAfterInvisibleChar(range)) {
var text = range.sc.textContent;
range.sc.textContent = text.slice(0, range.so - 1) + text.slice(range.so, text.length);
range.so -= 1;
}
return range;
},
/**
* Remove a char from a point's text node, at the point's offset.
*
* @private
* @param {Object} point
* @returns {Node}
*/
_removeCharAtOffset: function (point) {
var text = point.node.textContent;
var startToOffset = text.slice(0, point.offset);
var offsetToEnd = text.slice(point.offset + 1);
point.node.textContent = startToOffset + offsetToEnd;
return point.node;
},
/**
* Remove any amount of leading/trailing breakable space at range position.
* Then move the range and return it.
*
* @private
* @param {Object} range
* @returns {Object} range
*/
_removeExtremeBreakableSpaceAndRerange: function (range) {
var isInPre = !!dom.ancestor(range.sc, dom.isPre);
if (!range.sc.tagName && !isInPre) {
var changed = this.context.invoke('HelperPlugin.removeExtremeBreakableSpace', range.sc);
range.so = range.eo = range.so > changed.start ? range.so - changed.start : 0;
range.so = range.eo = range.so > dom.nodeLength(range.sc) ? dom.nodeLength(range.sc) : range.so;
range.select();
this.context.invoke('editor.saveRange');
}
return range;
},
/**
* Patch for Google Chrome's contenteditable SPAN bug.
*
* @private
* @param {jQueryEvent} e
*/
_removeGarbageSpans: function (e) {
if (e.target.className === "" && e.target.tagName == "SPAN" &&
e.target.style.fontStyle === "inherit" &&
e.target.style.fontVariantLigatures === "inherit" &&
e.target.style.fontVariantCaps === "inherit") {
var $span = $(e.target);
$span.after($span.contents()).remove();
}
},
/**
* Remove the first unbreakable ancestor's next sibling if not a block and empty.
*
* @private
* @param {Node} node
*/
_removeNextEmptyUnbreakable: function (node) {
var self = this;
var unbreakable = dom.ancestor(node, this.options.isUnbreakableNode);
if (unbreakable === this.editable) {
return;
}
var nextUnbreakable = unbreakable && unbreakable.nextElementSibling;
var isNextEmpty = nextUnbreakable && dom.isEmpty(nextUnbreakable) && !dom.isVoid(nextUnbreakable);
var isNextContainsOnlyInvisibleText = nextUnbreakable && _.all($(nextUnbreakable).contents(), function (n) {
return dom.isText(n) && !self.context.invoke('HelperPlugin.isVisibleText', n);
});
if (!dom.isBlock(nextUnbreakable) && (isNextEmpty || isNextContainsOnlyInvisibleText)) {
$(nextUnbreakable).remove();
}
},
/**
* If the range's start container is empty and constitutes the only contents of its parent,
* replace it with an empty p, then rerange.
*
* @private
* @param {Object} range
* @returns {Object|undefined} range
*/
_replaceEmptyParentWithEmptyP: function (range) {
var node = range.sc.childElementCount === 1 && range.sc.firstChild.tagName === 'BR' ? range.sc.firstChild : range.sc;
if (node === this.editable || !node.parentNode || node.parentNode === this.editable) {
return;
}
if (
dom.isEmpty(node) &&
this.context.invoke('HelperPlugin.onlyContains', node.parentNode, node) &&
['LI', 'P'].indexOf(node.parentNode.tagName) === -1
) {
var emptyP = this.document.createElement('p');
var br = this.document.createElement('br');
$(emptyP).append(br);
$(node.parentNode).before(emptyP).remove();
range.sc = range.ec = br;
range.so = range.eo = 0;
return range.collapse(true);
}
return;
},
/**
* Replace all leading space from a text node with one non-breakable space.
*
* @param {Node} node
* @returns {Node} node
*/
_replaceLeadingSpaceWithSingleNBSP: function (node) {
var startSpace = this.context.invoke('HelperPlugin.getRegex', 'startSpace');
node.textContent = node.textContent.replace(startSpace, '\u00A0');
return node;
},
/**
* Replace a media node with an empty SPAN and return that SPAN.
*
* @param {Node} media
* @returns {Node} span
*/
_replaceMediaWithEmptySpan: function (media) {
var span = this.document.createElement('span');
media = dom.ancestor(media, function (n) {
return !n.parentNode || !dom.isMedia(n.parentNode);
});
$(media).replaceWith(span);
return span;
},
/**
* Move the (collapsed) range to get out of BR elements.
*
* @private
* @param {Object} range
* @returns {Object} range
*/
_rerangeOutOfBR: function (range, direction) {
range = this._rerangeToFirstNonBRElementLeaf(range);
range = this._rerangeToNextNonBR(range, direction === 'next');
return range;
},
/**
* Move the (collapsed) range to the first leaf that is not a BR element.
*
* @private
* @param {Object} range
* @returns {Object} range
*/
_rerangeToFirstNonBRElementLeaf: function (range) {
var leaf = this.context.invoke('HelperPlugin.firstNonBRElementLeaf', range.sc);
if (leaf !== range.sc) {
range = this.context.invoke('editor.setRange', leaf, 0);
}
return range;
},
/**
* Move the (collapsed) range to the next (or previous) node that is not a BR element.
*
* @private
* @param {Object} range
* @param {Boolean} previous true to move to the previous node
* @returns {Object} range
*/
_rerangeToNextNonBR: function (range, previous) {
var point = range.getStartPoint();
var method = previous ? 'prevPointUntil' : 'nextPointUntil';
point = dom[method](point, function (pt) {
return pt.node.tagName !== 'BR';
});
range = this.context.invoke('editor.setRange', point.node, point.offset);
return range;
},
/**
* Move the (collapsed) range to the child of the node at the current offset if possible.
*
* @private
* @param {Object} range
* @param {String('prev'|'next')} direction
* @returns {Object} range
*/
_rerangeToOffsetChild: function (range, direction) {
if (range.sc.childNodes[range.so]) {
var node;
var offset;
if (direction === 'prev' && range.so > 0) {
node = range.sc.childNodes[range.so - 1];
offset = dom.nodeLength(node);
range = this.context.invoke('editor.setRange', node, offset);
} else {
node = range.sc.childNodes[range.so];
offset = 0;
range = this.context.invoke('editor.setRange', node, offset);
}
}
return range;
},
/**
* Select all the contents of the current unbreakable ancestor.
*/
_selectAll: function () {
var self = this;
var range = this.context.invoke('editor.createRange');
var unbreakable = dom.ancestor(range.sc, this.options.isUnbreakableNode);
var $contents = $(unbreakable).contents();
var startNode = $contents.length ? $contents[0] : unbreakable;
var pointA = this.context.invoke('HelperPlugin.makePoint', startNode, 0);
pointA = dom.nextPointUntil(pointA, function (point) {
return self.context.invoke('HelperPlugin.isVisibleText', point.node);
}) || pointA;
var endNode = $contents.length ? $contents[$contents.length - 1] : unbreakable;
var endOffset = $contents.length ? dom.nodeLength($contents[$contents.length - 1]) : 1;
var pointB = this.context.invoke('HelperPlugin.makePoint', endNode, endOffset);
pointB = dom.prevPointUntil(pointB, function (point) {
return self.context.invoke('HelperPlugin.isVisibleText', point.node);
}) || pointB;
range.sc = pointA.node;
range.so = pointA.offset;
range.ec = pointB.node;
range.eo = pointB.offset;
range.select().normalize();
},
/**
* Before a deletion, if necessary, slice the text content at range, then rerange.
*
* @param {Object} range
* @returns {Object} range
*/
_sliceAndRerangeBeforeDeletion: function (range) {
if (this._isAfterLeadingInvisibleChar(range) && !this._isAfterTwoBRs(range.sc)) {
range.sc.textContent = range.sc.textContent.slice(1);
range.so = 0;
}
if (this._isBeforeTrailingInvisibleChar(range) && !this._isAfterBR(range.sc)) {
range.sc.textContent = range.sc.textContent.slice(0, range.so);
}
return range;
},
//--------------------------------------------------------------------------
// Handle
//--------------------------------------------------------------------------
/**
* Customize handling of certain keydown events.
*
* @private
* @param {SummernoteEvent} se
* @param {jQueryEvent} e
* @returns {Boolean} true if case handled
*/
_onKeydown: function (se, e) {
if (e.ctrlKey && e.key === 'a') {
e.preventDefault();
this._selectAll();
return;
}
if (!keycode[e.keyCode]) {
return;
}
this.context.invoke('editor.clearTarget');
this.context.invoke('MediaPlugin.hidePopovers');
this.context.invoke('editor.beforeCommand');
var handled = false;
switch (keycode[e.keyCode]) {
case 'BACKSPACE':
handled = this._onBackspace(e);
break;
case 'TAB':
handled = this._onTab(e);
break;
case 'ENTER':
handled = this._onEnter(e);
break;
case 'DELETE':
handled = this._onDelete(e);
break;
}
if (handled) {
e.preventDefault();
this._preventTextInEditableDiv();
this.context.invoke('editor.saveRange');
this.context.invoke('editor.afterCommand');
}
return handled;
},
/**
* Handle BACKSPACE keydown event.
*
* @private
* @param {jQueryEvent} e
* @returns {Boolean} true if case is handled and event default must be prevented
*/
_onBackspace: function (e) {
var range = this.context.invoke('editor.createRange');
var needOutdent = false;
// Special cases
if (range.isCollapsed()) {
// Do nothing if on left edge of a table cell
var point = range.getStartPoint();
if (point.node.childNodes[point.offset]) {
point.node = point.node.childNodes[point.offset];
point.offset = dom.nodeLength(point.node);
}
if (this.context.invoke('HelperPlugin.isLeftEdgeOfTag', point, 'TD')) {
return true;
}
// Outdent if on left edge of an indented block
point = range.getStartPoint();
var isIndented = !!dom.ancestor(point.node, function (n) {
var style = dom.isCell(n) ? 'paddingLeft' : 'marginLeft';
return n.tagName && !!parseFloat(n.style[style] || 0);
});
if (this.context.invoke('HelperPlugin.isLeftEdgeOfBlock', point)) {
if (isIndented) {
this.context.invoke('BulletPlugin.outdent');
return true;
}
if (dom.ancestor(range.sc, dom.isLi)) {
needOutdent = true;
}
}
}
var flag = this._handleDeletion('prev');
if (!flag && needOutdent) {
range.select();
this.context.invoke('BulletPlugin.outdent');
}
return true;
},
/**
* Handle DELETE keydown event.
*
* @private
* @param {jQueryEvent} e
* @returns {Boolean} true if case is handled and event default must be prevented
*/
_onDelete: function (e) {
var range = this.context.invoke('editor.createRange');
// Special case
if (range.isCollapsed()) {
// Do nothing if on left edge of a table cell
if (this.context.invoke('HelperPlugin.isRightEdgeOfTag', range.getStartPoint(), 'TD')) {
return true;
}
}
this._handleDeletion('next');
return true;
},
/**
* Handle ENTER keydown event.
*
* @private
* @param {jQueryEvent} e
* @returns {Boolean} true if case is handled and event default must be prevented
*/
_onEnter: function (e) {
this.context.invoke('HelperPlugin.deleteSelection');
if (e.shiftKey) {
this._handleShiftEnter();
} else if (e.ctrlKey) {
this.context.invoke('TextPlugin.insertHR');
} else {
this._handleEnter();
}
return true;
},
/**
* Handle TAB keydown event.
*
* @private
* @param {jQueryEvent} e
* @returns {Boolean} true if case is handled and event default must be prevented
*/
_onTab: function (e) {
// If TAB not handled, prevent default and do nothing
if (!this.options.keyMap.pc.TAB) {
this.trigger_up('wysiwyg_blur', {
key: 'TAB',
keyCode: 9,
shiftKey: e.shiftKey,
});
return true;
}
var range = this.context.invoke('editor.createRange');
var point = range.getStartPoint();
var startSpace = this.context.invoke('HelperPlugin.getRegex', 'startSpace');
if (!range.isOnCell()) {
// If on left edge point: indent/outdent
if (!point.node.tagName) { // Clean up start spaces on textNode
point.node.textContent.replace(startSpace, function (startSpaces) {
point.offset = startSpaces.length === point.offset ? 0 : point.offset;
return '';
});
}
if (this.context.invoke('HelperPlugin.isLeftEdgeOfBlock', point) || dom.isEmpty(point.node)) {
if (e.shiftKey) {
this.context.invoke('BulletPlugin.outdent');
} else {
this.context.invoke('BulletPlugin.indent');
}
this.context.invoke('HelperPlugin.normalize');
return true;
}
// Otherwise insert a tab or do nothing
if (!e.shiftKey) {
this.context.invoke('TextPlugin.insertTab');
this.context.invoke('HelperPlugin.normalize');
}
return true;
}
// In table, on tab switch to next cell
return false;
},
/**
* Handle char input.
*
* @private
* @param {jQueryEvent} e
*/
_onKeyPress: function (e) {
console.log(e.originalEvent);
if (!isSafari || iPhone) {
e.preventDefault();
this.context.invoke('HelperPlugin.insertTextInline', e.key);
}
},
/**
* Handle char input.
*
* @private
* @param {jQueryEvent} e
*/
_onTextInput: function (e) {
if (isSafari && !iPhone) {
return;
}
e.originalEvent.preventDefault();
var text = e.originalEvent.data;
if (!text) {
return;
}
if (text === 'Spacebar') {
text = ' ';
}
this.context.invoke('HelperPlugin.insertTextInline', text);
},
});
registry.add('KeyboardPlugin', KeyboardPlugin);
return KeyboardPlugin;
});