medium-editor
Version:
Medium.com WYSIWYG editor clone.
254 lines (221 loc) • 12.3 kB
JavaScript
(function () {
'use strict';
var WHITESPACE_CHARS,
KNOWN_TLDS_FRAGMENT,
LINK_REGEXP_TEXT,
KNOWN_TLDS_REGEXP,
LINK_REGEXP;
WHITESPACE_CHARS = [' ', '\t', '\n', '\r', '\u00A0', '\u2000', '\u2001', '\u2002', '\u2003',
'\u2028', '\u2029'];
KNOWN_TLDS_FRAGMENT = 'com|net|org|edu|gov|mil|aero|asia|biz|cat|coop|info|int|jobs|mobi|museum|name|post|pro|tel|travel|' +
'xxx|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|' +
'bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|' +
'fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|' +
'is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|' +
'mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|' +
'pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|ja|sk|sl|sm|sn|so|sr|ss|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|' +
'tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw';
LINK_REGEXP_TEXT =
'(' +
// Version of Gruber URL Regexp optimized for JS: http://stackoverflow.com/a/17733640
'((?:(https?://|ftps?://|nntp://)|www\\d{0,3}[.]|[a-z0-9.\\-]+[.](' + KNOWN_TLDS_FRAGMENT + ')\\\/)\\S+(?:[^\\s`!\\[\\]{};:\'\".,?\u00AB\u00BB\u201C\u201D\u2018\u2019]))' +
// Addition to above Regexp to support bare domains/one level subdomains with common non-i18n TLDs and without www prefix:
')|(([a-z0-9\\-]+\\.)?[a-z0-9\\-]+\\.(' + KNOWN_TLDS_FRAGMENT + '))';
KNOWN_TLDS_REGEXP = new RegExp('^(' + KNOWN_TLDS_FRAGMENT + ')$', 'i');
LINK_REGEXP = new RegExp(LINK_REGEXP_TEXT, 'gi');
function nodeIsNotInsideAnchorTag(node) {
return !MediumEditor.util.getClosestTag(node, 'a');
}
var AutoLink = MediumEditor.Extension.extend({
init: function () {
MediumEditor.Extension.prototype.init.apply(this, arguments);
this.disableEventHandling = false;
this.subscribe('editableKeypress', this.onKeypress.bind(this));
this.subscribe('editableBlur', this.onBlur.bind(this));
// MS IE has it's own auto-URL detect feature but ours is better in some ways. Be consistent.
this.document.execCommand('AutoUrlDetect', false, false);
},
isLastInstance: function () {
var activeInstances = 0;
for (var i = 0; i < this.window._mediumEditors.length; i++) {
var editor = this.window._mediumEditors[i];
if (editor !== null && editor.getExtensionByName('autoLink') !== undefined) {
activeInstances++;
}
}
return activeInstances === 1;
},
destroy: function () {
// Turn AutoUrlDetect back on
if (this.document.queryCommandSupported('AutoUrlDetect') && this.isLastInstance()) {
this.document.execCommand('AutoUrlDetect', false, true);
}
},
onBlur: function (blurEvent, editable) {
this.performLinking(editable);
},
onKeypress: function (keyPressEvent) {
if (this.disableEventHandling) {
return;
}
if (MediumEditor.util.isKey(keyPressEvent, [MediumEditor.util.keyCode.SPACE, MediumEditor.util.keyCode.ENTER])) {
clearTimeout(this.performLinkingTimeout);
// Saving/restoring the selection in the middle of a keypress doesn't work well...
this.performLinkingTimeout = setTimeout(function () {
try {
var sel = this.base.exportSelection();
if (this.performLinking(keyPressEvent.target)) {
// pass true for favorLaterSelectionAnchor - this is needed for links at the end of a
// paragraph in MS IE, or MS IE causes the link to be deleted right after adding it.
this.base.importSelection(sel, true);
}
} catch (e) {
if (window.console) {
window.console.error('Failed to perform linking', e);
}
this.disableEventHandling = true;
}
}.bind(this), 0);
}
},
performLinking: function (contenteditable) {
/*
Perform linking on blockElement basis, blockElements are HTML elements with text content and without
child element.
Example:
- HTML content
<blockquote>
<p>link.</p>
<p>my</p>
</blockquote>
- blockElements
[<p>link.</p>, <p>my</p>]
otherwise the detection can wrongly find the end of one paragraph and the beginning of another paragraph
to constitute a link, such as a paragraph ending "link." and the next paragraph beginning with "my" is
interpreted into "link.my" and the code tries to create a link across blockElements - which doesn't work
and is terrible.
(Medium deletes the spaces/returns between P tags so the textContent ends up without paragraph spacing)
*/
var blockElements = MediumEditor.util.splitByBlockElements(contenteditable),
documentModified = false;
if (blockElements.length === 0) {
blockElements = [contenteditable];
}
for (var i = 0; i < blockElements.length; i++) {
documentModified = this.removeObsoleteAutoLinkSpans(blockElements[i]) || documentModified;
documentModified = this.performLinkingWithinElement(blockElements[i]) || documentModified;
}
this.base.events.updateInput(contenteditable, { target: contenteditable, currentTarget: contenteditable });
return documentModified;
},
removeObsoleteAutoLinkSpans: function (element) {
if (!element || element.nodeType === 3) {
return false;
}
var spans = element.querySelectorAll('span[data-auto-link="true"]'),
documentModified = false;
for (var i = 0; i < spans.length; i++) {
var textContent = spans[i].textContent;
if (textContent.indexOf('://') === -1) {
textContent = MediumEditor.util.ensureUrlHasProtocol(textContent);
}
if (spans[i].getAttribute('data-href') !== textContent && nodeIsNotInsideAnchorTag(spans[i])) {
documentModified = true;
var trimmedTextContent = textContent.replace(/\s+$/, '');
if (spans[i].getAttribute('data-href') === trimmedTextContent) {
var charactersTrimmed = textContent.length - trimmedTextContent.length,
subtree = MediumEditor.util.splitOffDOMTree(spans[i], this.splitTextBeforeEnd(spans[i], charactersTrimmed));
spans[i].parentNode.insertBefore(subtree, spans[i].nextSibling);
} else {
// Some editing has happened to the span, so just remove it entirely. The user can put it back
// around just the href content if they need to prevent it from linking
MediumEditor.util.unwrap(spans[i], this.document);
}
}
}
return documentModified;
},
splitTextBeforeEnd: function (element, characterCount) {
var treeWalker = this.document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false),
lastChildNotExhausted = true;
// Start the tree walker at the last descendant of the span
while (lastChildNotExhausted) {
lastChildNotExhausted = treeWalker.lastChild() !== null;
}
var currentNode,
currentNodeValue,
previousNode;
while (characterCount > 0 && previousNode !== null) {
currentNode = treeWalker.currentNode;
currentNodeValue = currentNode.nodeValue;
if (currentNodeValue.length > characterCount) {
previousNode = currentNode.splitText(currentNodeValue.length - characterCount);
characterCount = 0;
} else {
previousNode = treeWalker.previousNode();
characterCount -= currentNodeValue.length;
}
}
return previousNode;
},
performLinkingWithinElement: function (element) {
var matches = this.findLinkableText(element),
linkCreated = false;
for (var matchIndex = 0; matchIndex < matches.length; matchIndex++) {
var matchingTextNodes = MediumEditor.util.findOrCreateMatchingTextNodes(this.document, element,
matches[matchIndex]);
if (this.shouldNotLink(matchingTextNodes)) {
continue;
}
this.createAutoLink(matchingTextNodes, matches[matchIndex].href);
}
return linkCreated;
},
shouldNotLink: function (textNodes) {
var shouldNotLink = false;
for (var i = 0; i < textNodes.length && shouldNotLink === false; i++) {
// Do not link if the text node is either inside an anchor or inside span[data-auto-link]
shouldNotLink = !!MediumEditor.util.traverseUp(textNodes[i], function (node) {
return node.nodeName.toLowerCase() === 'a' ||
(node.getAttribute && node.getAttribute('data-auto-link') === 'true');
});
}
return shouldNotLink;
},
findLinkableText: function (contenteditable) {
var textContent = contenteditable.textContent,
match = null,
matches = [];
while ((match = LINK_REGEXP.exec(textContent)) !== null) {
var matchOk = true,
matchEnd = match.index + match[0].length;
// If the regexp detected something as a link that has text immediately preceding/following it, bail out.
matchOk = (match.index === 0 || WHITESPACE_CHARS.indexOf(textContent[match.index - 1]) !== -1) &&
(matchEnd === textContent.length || WHITESPACE_CHARS.indexOf(textContent[matchEnd]) !== -1);
// If the regexp detected a bare domain that doesn't use one of our expected TLDs, bail out.
matchOk = matchOk && (match[0].indexOf('/') !== -1 ||
KNOWN_TLDS_REGEXP.test(match[0].split('.').pop().split('?').shift()));
if (matchOk) {
matches.push({
href: match[0],
start: match.index,
end: matchEnd
});
}
}
return matches;
},
createAutoLink: function (textNodes, href) {
href = MediumEditor.util.ensureUrlHasProtocol(href);
var anchor = MediumEditor.util.createLink(this.document, textNodes, href, this.getEditorOption('targetBlank') ? '_blank' : null),
span = this.document.createElement('span');
span.setAttribute('data-auto-link', 'true');
span.setAttribute('data-href', href);
anchor.insertBefore(span, anchor.firstChild);
while (anchor.childNodes.length > 1) {
span.appendChild(anchor.childNodes[1]);
}
}
});
MediumEditor.extensions.autoLink = AutoLink;
}());