UNPKG

medium-editor

Version:
254 lines (221 loc) 12.3 kB
(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; }());