upfront-editable
Version:
Friendly contenteditable API
430 lines (337 loc) • 14.4 kB
JavaScript
;
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
var _typeof = require("@babel/runtime/helpers/typeof");
Object.defineProperty(exports, "__esModule", {
value: true
});
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 = _interopRequireDefault(require("jquery"));
var _rangy = _interopRequireDefault(require("rangy"));
var nodeType = _interopRequireWildcard(require("./node-type"));
var rangeSaveRestore = _interopRequireWildcard(require("./range-save-restore"));
var parser = _interopRequireWildcard(require("./parser"));
var string = _interopRequireWildcard(require("./util/string"));
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || _typeof(obj) !== "object" && typeof obj !== "function") { return { "default": obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj["default"] = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
function restoreRange(host, range, func) {
range = rangeSaveRestore.save(range);
func();
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;
Array.from(sibling.childNodes).forEach(function (siblingChild) {
node.appendChild(siblingChild.cloneNode(true));
});
sibling.remove();
}
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 fragment} 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 elements 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, _jquery["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, _jquery["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 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 = _rangy["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 = _rangy["default"].createRange();
elemRange.selectNodeContents(elem);
if (!range.intersectsRange(elemRange)) return false;
var rangeText = range.toString();
var elemText = (0, _jquery["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, _jquery["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, _jquery["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 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);
});
}