upfront-editable
Version:
Friendly contenteditable API
449 lines (359 loc) • 13.7 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
var _from = require('babel-runtime/core-js/array/from');
var _from2 = _interopRequireDefault(_from);
exports.tidyHtml = tidyHtml;
exports.normalizeTags = normalizeTags;
exports.normalizeWhitespace = normalizeWhitespace;
exports.cleanInternals = cleanInternals;
exports.extractContent = extractContent;
exports.getInnerHtmlOfFragment = getInnerHtmlOfFragment;
exports.createFragmentFromString = createFragmentFromString;
exports.adoptElement = adoptElement;
exports.cloneRangeContents = cloneRangeContents;
exports.unwrapInternalNodes = unwrapInternalNodes;
exports.getTags = getTags;
exports.getTagsByName = getTagsByName;
exports.getTagsByNameAndAttributes = getTagsByNameAndAttributes;
exports.areSameAttributes = areSameAttributes;
exports.getInnerTags = getInnerTags;
exports.getTagNames = getTagNames;
exports.isAffectedBy = isAffectedBy;
exports.selectNodeContents = selectNodeContents;
exports.isExactSelection = isExactSelection;
exports.expandTo = expandTo;
exports.toggleTag = toggleTag;
exports.isWrappable = isWrappable;
exports.forceWrap = forceWrap;
exports.wrap = wrap;
exports.unwrap = unwrap;
exports.removeFormattingElem = removeFormattingElem;
exports.removeFormatting = removeFormatting;
exports.nuke = nuke;
exports.nukeElem = nukeElem;
exports.insertCharacter = insertCharacter;
exports.surround = surround;
exports.deleteCharacter = deleteCharacter;
exports.containsString = containsString;
exports.nukeTag = nukeTag;
var _jquery = require('jquery');
var _jquery2 = _interopRequireDefault(_jquery);
var _rangy = require('rangy');
var _rangy2 = _interopRequireDefault(_rangy);
var _nodeType = require('./node-type');
var nodeType = _interopRequireWildcard(_nodeType);
var _rangeSaveRestore = require('./range-save-restore');
var rangeSaveRestore = _interopRequireWildcard(_rangeSaveRestore);
var _parser = require('./parser');
var parser = _interopRequireWildcard(_parser);
var _string = require('./util/string');
var string = _interopRequireWildcard(_string);
function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } }
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function restoreRange(host, range, func) {
range = rangeSaveRestore.save(range);
func.call(exports);
return rangeSaveRestore.restore(host, range);
}
var zeroWidthSpace = /\u200B/g;
var zeroWidthNonBreakingSpace = /\uFEFF/g;
var whitespaceExceptSpace = /[^\S ]/g;
// Clean up the Html.
function tidyHtml(element) {
// if (element.normalize) element.normalize()
normalizeTags(element);
}
// Remove empty tags and merge consecutive tags (they must have the same
// attributes).
//
// @method normalizeTags
// @param {HTMLElement} element The element to process.
function normalizeTags(element) {
var fragment = document.createDocumentFragment();
Array.prototype.forEach.call(element.childNodes, function (node) {
// skip empty tags, so they'll get removed
if (node.nodeName !== 'BR' && !node.textContent) return;
if (node.nodeType === nodeType.elementNode && node.nodeName !== 'BR') {
var sibling = node;
while ((sibling = sibling.nextSibling) !== null) {
if (!parser.isSameNode(sibling, node)) break;
(0, _from2.default)(sibling.childNodes).forEach(function (siblingChild) {
node.appendChild(siblingChild.cloneNode(true));
});
sibling.parentNode.removeChild(sibling);
}
normalizeTags(node);
}
fragment.appendChild(node.cloneNode(true));
});
while (element.firstChild) {
element.removeChild(element.firstChild);
}element.appendChild(fragment);
}
function normalizeWhitespace(text) {
return text.replace(whitespaceExceptSpace, ' ');
}
// Clean the element from character, tags, etc... added by the plugin logic.
//
// @method cleanInternals
// @param {HTMLElement} element The element to process.
function cleanInternals(element) {
// Uses extract content for simplicity. A custom method
// that does not clone the element could be faster if needed.
element.innerHTML = extractContent(element, true);
}
// Extracts the content from a host element.
// Does not touch or change the host. Just returns
// the content and removes elements marked for removal by editable.
//
// @param {DOM node or document framgent} Element where to clean out the innerHTML. If you pass a document fragment it will be empty after this call.
// @param {Boolean} Flag whether to keep ui elements like spellchecking highlights.
// @returns {String} The cleaned innerHTML of the passed element or document fragment.
function extractContent(element, keepUiElements) {
var innerHtml = (element.nodeType === nodeType.documentFragmentNode ? getInnerHtmlOfFragment(element) : element.innerHTML).replace(zeroWidthNonBreakingSpace, '') // Used for forcing inline elments to have a height
.replace(zeroWidthSpace, '<br>'); // Used for cross-browser newlines
var clone = document.createElement('div');
clone.innerHTML = innerHtml;
unwrapInternalNodes(clone, keepUiElements);
return clone.innerHTML;
}
function getInnerHtmlOfFragment(documentFragment) {
var div = document.createElement('div');
div.appendChild(documentFragment);
return div.innerHTML;
}
// Create a document fragment from an html string
// @param {String} e.g. 'some html <span>text</span>.'
function createFragmentFromString(htmlString) {
var fragment = document.createDocumentFragment();
(0, _jquery2.default)('<div>').html(htmlString).contents().each(function (i, el) {
fragment.appendChild(el);
});
return fragment;
}
function adoptElement(node, doc) {
return node.ownerDocument !== doc ? doc.adoptNode(node) : node;
}
// This is a slight variation of the cloneContents method of a rangyRange.
// It will return a fragment with the cloned contents of the range
// without the commonAncestorElement.
//
// @param {rangyRange}
// @return {DocumentFragment}
function cloneRangeContents(range) {
var rangeFragment = range.cloneContents();
var parent = rangeFragment.childNodes[0];
var fragment = document.createDocumentFragment();
while (parent.childNodes.length) {
fragment.appendChild(parent.childNodes[0]);
}return fragment;
}
// Remove elements that were inserted for internal or user interface purposes
//
// @param {DOM node}
// @param {Boolean} whether to keep ui elements like spellchecking highlights
// Currently:
// - Saved ranges
function unwrapInternalNodes(sibling, keepUiElements) {
while (sibling) {
var nextSibling = sibling.nextSibling;
if (sibling.nodeType !== nodeType.elementNode) {
sibling = nextSibling;
continue;
}
var attr = sibling.getAttribute('data-editable');
if (sibling.firstChild) unwrapInternalNodes(sibling.firstChild, keepUiElements);
if (attr === 'remove' || attr === 'ui-remove' && !keepUiElements) {
(0, _jquery2.default)(sibling).remove();
}if (attr === 'unwrap' || attr === 'ui-unwrap' && !keepUiElements) {
unwrap(sibling);
}
sibling = nextSibling;
}
}
// Get all tags that start or end inside the range
function getTags(host, range, filterFunc) {
var tags = getInnerTags(range, filterFunc);
// get all tags that surround the range
var node = range.commonAncestorContainer;
while (node !== host) {
if (!filterFunc || filterFunc(node)) tags.push(node);
node = node.parentNode;
}
return tags;
}
function getTagsByName(host, range, tagName) {
return getTags(host, range, function (node) {
return node.nodeName.toUpperCase() === tagName.toUpperCase();
});
}
function getTagsByNameAndAttributes(host, range, elem) {
return getTags(host, range, function (node) {
return node.nodeName.toUpperCase() === elem.nodeName.toUpperCase() && areSameAttributes(node.attributes, elem.attributes);
});
}
function areSameAttributes(attrs1, attrs2) {
if (attrs1.length !== attrs2.length) return false;
for (var i = 0; i < attrs1.length; i++) {
var attr = attrs2[attrs1[i].name];
if (!(attr && attr.value === attrs1[i].value)) return false;
}
return true;
}
// Get all tags that start or end inside the range
function getInnerTags(range, filterFunc) {
return range.getNodes([nodeType.elementNode], filterFunc);
}
// Transform an array of elements into a an array
// of tagnames in uppercase
//
// @return example: ['STRONG', 'B']
function getTagNames() {
var elements = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
return elements.map(function (element) {
return element.nodeName;
});
}
function isAffectedBy(host, range, tagName) {
return getTags(host, range).some(function (elem) {
return elem.nodeName === tagName.toUpperCase();
});
}
// select a whole element
function selectNodeContents(element) {
var range = _rangy2.default.createRange();
range.selectNodeContents(element);
return range;
}
// Check if the range selects all of the elements contents,
// not less or more.
//
// @param visible: Only compare visible text. That way it does not
// matter if the user selects an additional whitespace or not.
function isExactSelection(range, elem, visible) {
var elemRange = _rangy2.default.createRange();
elemRange.selectNodeContents(elem);
if (!range.intersectsRange(elemRange)) return false;
var rangeText = range.toString();
var elemText = (0, _jquery2.default)(elem).text();
if (visible) {
rangeText = string.trim(rangeText);
elemText = string.trim(elemText);
}
return rangeText !== '' && rangeText === elemText;
}
function expandTo(host, range, elem) {
range.selectNodeContents(elem);
return range;
}
function toggleTag(host, range, elem) {
var elems = getTagsByNameAndAttributes(host, range, elem);
if (elems.length === 1 && isExactSelection(range, elems[0], 'visible')) {
return removeFormattingElem(host, range, elem);
}
return forceWrap(host, range, elem);
}
function isWrappable(range) {
return range.canSurroundContents();
}
function forceWrap(host, range, elem) {
var restoredRange = restoreRange(host, range, function () {
nukeElem(host, range, elem);
});
// remove all tags if the range is not wrappable
if (!isWrappable(restoredRange)) {
restoredRange = restoreRange(host, restoredRange, function () {
nuke(host, restoredRange);
});
}
wrap(restoredRange, elem);
return restoredRange;
}
function wrap(range, elem) {
var el = string.isString(elem) ? (0, _jquery2.default)(elem).get(0) : elem;
if (!isWrappable(range)) {
console.log('content.wrap(): can not surround range');
return;
}
range.surroundContents(el);
}
function unwrap(elem) {
var $elem = (0, _jquery2.default)(elem);
var contents = $elem.contents();
contents.length ? contents.unwrap() : $elem.remove();
}
function removeFormattingElem(host, range, elem) {
return restoreRange(host, range, function () {
nukeElem(host, range, elem);
});
}
function removeFormatting(host, range, tagName) {
return restoreRange(host, range, function () {
nuke(host, range, tagName);
});
}
// Unwrap all tags this range is affected by.
// Can also affect content outside of the range.
function nuke(host, range, tagName) {
getTags(host, range).forEach(function (elem) {
if (elem.nodeName.toUpperCase() !== 'BR' && (!tagName || elem.nodeName.toUpperCase() === tagName.toUpperCase())) {
unwrap(elem);
}
});
}
// Unwrap all tags this range is affected by.
// Can also affect content outside of the range.
function nukeElem(host, range, node) {
getTags(host, range).forEach(function (elem) {
if (elem.nodeName.toUpperCase() !== 'BR' && (!node || elem.nodeName.toUpperCase() === node.nodeName.toUpperCase() && areSameAttributes(elem.attributes, node.attributes))) {
unwrap(elem);
}
});
}
// Insert a single character (or string) before or after the
// the range.
function insertCharacter(range, character, atStart) {
var insertEl = document.createTextNode(character);
var boundaryRange = range.cloneRange();
boundaryRange.collapse(atStart);
boundaryRange.insertNode(insertEl);
range[atStart ? 'setStartBefore' : 'setEndAfter'](insertEl);
range.normalizeBoundaries();
}
// Surround the range with characters like start and end quotes.
//
// @method surround
function surround(host, range, startCharacter, endCharacter) {
insertCharacter(range, endCharacter || startCharacter, false);
insertCharacter(range, startCharacter, true);
return range;
}
// Removes a character from the text within a range.
//
// @method deleteCharacter
function deleteCharacter(host, range, character) {
if (!containsString(range, character)) return range;
range.splitBoundaries();
var restoredRange = restoreRange(host, range, function () {
var charRegexp = string.regexp(character);
range.getNodes([nodeType.textNode], function (node) {
return node.nodeValue.search(charRegexp) >= 0;
}).forEach(function (node) {
node.nodeValue = node.nodeValue.replace(charRegexp, '');
});
});
restoredRange.normalizeBoundaries();
return restoredRange;
}
function containsString(range, str) {
return range.toString().indexOf(str) >= 0;
}
// Unwrap all tags this range is affected by.
// Can also affect content outside of the range.
function nukeTag(host, range, tagName) {
getTags(host, range).forEach(function (elem) {
if (elem.nodeName.toUpperCase() === tagName.toUpperCase()) unwrap(elem);
});
}