vendor: 23,006 bytes, lines 1-656
1/*global self, document, DOMException */ 2 3/*! @source http://purl.eligrey.com/github/classList.js/blob/master/classList.js */ 4 5// Full polyfill for browsers with no classList support 6if (!("classList" in document.createElement("_"))) { 7 (function (view) { 8 9 "use strict"; 10 11 if (!('Element' in view)) return; 12 13 var 14 classListProp = "classList" 15 , protoProp = "prototype" 16 , elemCtrProto = view.Element[protoProp] 17 , objCtr = Object 18 , strTrim = String[protoProp].trim || function () { 19 return this.replace(/^\s+|\s+$/g, ""); 20 } 21 , arrIndexOf = Array[protoProp].indexOf || function (item) { 22 var 23 i = 0 24 , len = this.length 25 ; 26 for (; i < len; i++) { 27 if (i in this && this[i] === item) { 28 return i; 29 } 30 } 31 return -1; 32 } 33 // Vendors: please allow content code to instantiate DOMExceptions 34 , DOMEx = function (type, message) { 35 this.name = type; 36 this.code = DOMException[type]; 37 this.message = message; 38 } 39 , checkTokenAndGetIndex = function (classList, token) { 40 if (token === "") { 41 throw new DOMEx( 42 "SYNTAX_ERR" 43 , "An invalid or illegal string was specified" 44 ); 45 } 46 if (/\s/.test(token)) { 47 throw new DOMEx( 48 "INVALID_CHARACTER_ERR" 49 , "String contains an invalid character" 50 ); 51 } 52 return arrIndexOf.call(classList, token); 53 } 54 , ClassList = function (elem) { 55 var 56 trimmedClasses = strTrim.call(elem.getAttribute("class") || "") 57 , classes = trimmedClasses ? trimmedClasses.split(/\s+/) : [] 58 , i = 0 59 , len = classes.length 60 ; 61 for (; i < len; i++) { 62 this.push(classes[i]); 63 } 64 this._updateClassName = function () { 65 elem.setAttribute("class", this.toString()); 66 }; 67 } 68 , classListProto = ClassList[protoProp] = [] 69 , classListGetter = function () { 70 return new ClassList(this); 71 } 72 ; 73 // Most DOMException implementations don't allow calling DOMException's toString() 74 // on non-DOMExceptions. Error's toString() is sufficient here. 75 DOMEx[protoProp] = Error[protoProp]; 76 classListProto.item = function (i) { 77 return this[i] || null; 78 }; 79 classListProto.contains = function (token) { 80 token += ""; 81 return checkTokenAndGetIndex(this, token) !== -1; 82 }; 83 classListProto.add = function () { 84 var 85 tokens = arguments 86 , i = 0 87 , l = tokens.length 88 , token 89 , updated = false 90 ; 91 do { 92 token = tokens[i] + ""; 93 if (checkTokenAndGetIndex(this, token) === -1) { 94 this.push(token); 95 updated = true; 96 } 97 } 98 while (++i < l); 99 100 if (updated) { 101 this._updateClassName(); 102 } 103 }; 104 classListProto.remove = function () { 105 var 106 tokens = arguments 107 , i = 0 108 , l = tokens.length 109 , token 110 , updated = false 111 , index 112 ; 113 do { 114 token = tokens[i] + ""; 115 index = checkTokenAndGetIndex(this, token); 116 while (index !== -1) { 117 this.splice(index, 1); 118 updated = true; 119 index = checkTokenAndGetIndex(this, token); 120 } 121 } 122 while (++i < l); 123 124 if (updated) { 125 this._updateClassName(); 126 } 127 }; 128 classListProto.toggle = function (token, force) { 129 token += ""; 130 131 var 132 result = this.contains(token) 133 , method = result ? 134 force !== true && "remove" 135 : 136 force !== false && "add" 137 ; 138 139 if (method) { 140 this[method](token); 141 } 142 143 if (force === true || force === false) { 144 return force; 145 } else { 146 return !result; 147 } 148 }; 149 classListProto.toString = function () { 150 return this.join(" "); 151 }; 152 153 if (objCtr.defineProperty) { 154 var classListPropDesc = { 155 get: classListGetter 156 , enumerable: true 157 , configurable: true 158 }; 159 try { 160 objCtr.defineProperty(elemCtrProto, classListProp, classListPropDesc); 161 } catch (ex) { // IE 8 doesn't support enumerable:true 162 if (ex.number === -0x7FF5EC54) { 163 classListPropDesc.enumerable = false; 164 objCtr.defineProperty(elemCtrProto, classListProp, classListPropDesc); 165 } 166 } 167 } else if (objCtr[protoProp].__defineGetter__) { 168 elemCtrProto.__defineGetter__(classListProp, classListGetter); 169 } 170 171 }(self)); 172} 173 174/* Blob.js 175 * A Blob implementation. 176 * 2014-07-24 177 * 178 * By Eli Grey, http://eligrey.com 179 * By Devin Samarin, https://github.com/dsamarin 180 * License: X11/MIT 181 * See https://github.com/eligrey/Blob.js/blob/master/LICENSE.md 182 */ 183 184/*global self, unescape */ 185/*jslint bitwise: true, regexp: true, confusion: true, es5: true, vars: true, white: true, 186 plusplus: true */ 187 188/*! @source http://purl.eligrey.com/github/Blob.js/blob/master/Blob.js */ 189 190(function (view) { 191 "use strict"; 192 193 view.URL = view.URL || view.webkitURL; 194 195 if (view.Blob && view.URL) { 196 try { 197 new Blob; 198 return; 199 } catch (e) {} 200 } 201 202 // Internally we use a BlobBuilder implementation to base Blob off of 203 // in order to support older browsers that only have BlobBuilder 204 var BlobBuilder = view.BlobBuilder || view.WebKitBlobBuilder || view.MozBlobBuilder || (function(view) { 205 var 206 get_class = function(object) { 207 return Object.prototype.toString.call(object).match(/^\[object\s(.*)\]$/)[1]; 208 } 209 , FakeBlobBuilder = function BlobBuilder() { 210 this.data = []; 211 } 212 , FakeBlob = function Blob(data, type, encoding) { 213 this.data = data; 214 this.size = data.length; 215 this.type = type; 216 this.encoding = encoding; 217 } 218 , FBB_proto = FakeBlobBuilder.prototype 219 , FB_proto = FakeBlob.prototype 220 , FileReaderSync = view.FileReaderSync 221 , FileException = function(type) { 222 this.code = this[this.name = type]; 223 } 224 , file_ex_codes = ( 225 "NOT_FOUND_ERR SECURITY_ERR ABORT_ERR NOT_READABLE_ERR ENCODING_ERR " 226 + "NO_MODIFICATION_ALLOWED_ERR INVALID_STATE_ERR SYNTAX_ERR" 227 ).split(" ") 228 , file_ex_code = file_ex_codes.length 229 , real_URL = view.URL || view.webkitURL || view 230 , real_create_object_URL = real_URL.createObjectURL 231 , real_revoke_object_URL = real_URL.revokeObjectURL 232 , URL = real_URL 233 , btoa = view.btoa 234 , atob = view.atob 235 236 , ArrayBuffer = view.ArrayBuffer 237 , Uint8Array = view.Uint8Array 238 239 , origin = /^[\w-]+:\/*\[?[\w\.:-]+\]?(?::[0-9]+)?/ 240 ; 241 FakeBlob.fake = FB_proto.fake = true; 242 while (file_ex_code--) { 243 FileException.prototype[file_ex_codes[file_ex_code]] = file_ex_code + 1; 244 } 245 // Polyfill URL 246 if (!real_URL.createObjectURL) { 247 URL = view.URL = function(uri) { 248 var 249 uri_info = document.createElementNS("http://www.w3.org/1999/xhtml", "a") 250 , uri_origin 251 ; 252 uri_info.href = uri; 253 if (!("origin" in uri_info)) { 254 if (uri_info.protocol.toLowerCase() === "data:") { 255 uri_info.origin = null; 256 } else { 257 uri_origin = uri.match(origin); 258 uri_info.origin = uri_origin && uri_origin[1]; 259 } 260 } 261 return uri_info; 262 }; 263 } 264 URL.createObjectURL = function(blob) { 265 var 266 type = blob.type 267 , data_URI_header 268 ; 269 if (type === null) { 270 type = "application/octet-stream"; 271 } 272 if (blob instanceof FakeBlob) { 273 data_URI_header = "data:" + type; 274 if (blob.encoding === "base64") { 275 return data_URI_header + ";base64," + blob.data; 276 } else if (blob.encoding === "URI") { 277 return data_URI_header + "," + decodeURIComponent(blob.data); 278 } if (btoa) { 279 return data_URI_header + ";base64," + btoa(blob.data); 280 } else { 281 return data_URI_header + "," + encodeURIComponent(blob.data); 282 } 283 } else if (real_create_object_URL) { 284 return real_create_object_URL.call(real_URL, blob); 285 } 286 }; 287 URL.revokeObjectURL = function(object_URL) { 288 if (object_URL.substring(0, 5) !== "data:" && real_revoke_object_URL) { 289 real_revoke_object_URL.call(real_URL, object_URL); 290 } 291 }; 292 FBB_proto.append = function(data/*, endings*/) { 293 var bb = this.data; 294 // decode data to a binary string 295 if (Uint8Array && (data instanceof ArrayBuffer || data instanceof Uint8Array)) { 296 var 297 str = "" 298 , buf = new Uint8Array(data) 299 , i = 0 300 , buf_len = buf.length 301 ; 302 for (; i < buf_len; i++) { 303 str += String.fromCharCode(buf[i]); 304 } 305 bb.push(str); 306 } else if (get_class(data) === "Blob" || get_class(data) === "File") { 307 if (FileReaderSync) { 308 var fr = new FileReaderSync; 309 bb.push(fr.readAsBinaryString(data)); 310 } else { 311 // async FileReader won't work as BlobBuilder is sync 312 throw new FileException("NOT_READABLE_ERR"); 313 } 314 } else if (data instanceof FakeBlob) { 315 if (data.encoding === "base64" && atob) { 316 bb.push(atob(data.data)); 317 } else if (data.encoding === "URI") { 318 bb.push(decodeURIComponent(data.data)); 319 } else if (data.encoding === "raw") { 320 bb.push(data.data); 321 } 322 } else { 323 if (typeof data !== "string") { 324 data += ""; // convert unsupported types to strings 325 } 326 // decode UTF-16 to binary string 327 bb.push(unescape(encodeURIComponent(data))); 328 } 329 }; 330 FBB_proto.getBlob = function(type) { 331 if (!arguments.length) { 332 type = null; 333 } 334 return new FakeBlob(this.data.join(""), type, "raw"); 335 }; 336 FBB_proto.toString = function() { 337 return "[object BlobBuilder]"; 338 }; 339 FB_proto.slice = function(start, end, type) { 340 var args = arguments.length; 341 if (args < 3) { 342 type = null; 343 } 344 return new FakeBlob( 345 this.data.slice(start, args > 1 ? end : this.data.length) 346 , type 347 , this.encoding 348 ); 349 }; 350 FB_proto.toString = function() { 351 return "[object Blob]"; 352 }; 353 FB_proto.close = function() { 354 this.size = 0; 355 delete this.data; 356 }; 357 return FakeBlobBuilder; 358 }(view)); 359 360 view.Blob = function(blobParts, options) { 361 var type = options ? (options.type || "") : ""; 362 var builder = new BlobBuilder(); 363 if (blobParts) { 364 for (var i = 0, len = blobParts.length; i < len; i++) { 365 if (Uint8Array && blobParts[i] instanceof Uint8Array) { 366 builder.append(blobParts[i].buffer); 367 } 368 else { 369 builder.append(blobParts[i]); 370 } 371 } 372 } 373 var blob = builder.getBlob(type); 374 if (!blob.slice && blob.webkitSlice) { 375 blob.slice = blob.webkitSlice; 376 } 377 return blob; 378 }; 379 380 var getPrototypeOf = Object.getPrototypeOf || function(object) { 381 return object.__proto__; 382 }; 383 view.Blob.prototype = getPrototypeOf(new view.Blob()); 384}(typeof self !== "undefined" && self || typeof window !== "undefined" && window || this.content || this)); 385 386(function (root, factory) { 387 'use strict'; 388 var isElectron = typeof module === 'object' && typeof process !== 'undefined' && process && process.versions && process.versions.electron; 389 if (!isElectron && typeof module === 'object') { 390 module.exports = factory; 391 } else if (typeof define === 'function' && define.amd) { 392 define(function () { 393 return factory; 394 }); 395 } else { 396 root.MediumEditor = factory; 397 } 398}(this, function () { 399 400 'use strict'; 401 402function MediumEditor(elements, options) { 403 'use strict'; 404 return this.init(elements, options); 405} 406 407MediumEditor.extensions = {}; 408/*jshint unused: true */ 409(function (window) { 410 'use strict'; 411 412 function copyInto(overwrite, dest) { 413 var prop, 414 sources = Array.prototype.slice.call(arguments, 2); 415 dest = dest || {}; 416 for (var i = 0; i < sources.length; i++) { 417 var source = sources[i]; 418 if (source) { 419 for (prop in source) { 420 if (source.hasOwnProperty(prop) && 421 typeof source[prop] !== 'undefined' && 422 (overwrite || dest.hasOwnProperty(prop) === false)) { 423 dest[prop] = source[prop]; 424 } 425 } 426 } 427 } 428 return dest; 429 } 430 431 // https://developer.mozilla.org/en-US/docs/Web/API/Node/contains 432 // Some browsers (including phantom) don't return true for Node.contains(child) 433 // if child is a text node. Detect these cases here and use a fallback 434 // for calls to Util.isDescendant() 435 var nodeContainsWorksWithTextNodes = false; 436 try { 437 var testParent = document.createElement('div'), 438 testText = document.createTextNode(' '); 439 testParent.appendChild(testText); 440 nodeContainsWorksWithTextNodes = testParent.contains(testText); 441 } catch (exc) {} 442 443 var Util = { 444 445 // http://stackoverflow.com/questions/17907445/how-to-detect-ie11#comment30165888_17907562 446 // by rg89 447 isIE: ((navigator.appName === 'Microsoft Internet Explorer') || ((navigator.appName === 'Netscape') && (new RegExp('Trident/.*rv:([0-9]{1,}[.0-9]{0,})').exec(navigator.userAgent) !== null))), 448 449 isEdge: (/Edge\/\d+/).exec(navigator.userAgent) !== null, 450 451 // if firefox 452 isFF: (navigator.userAgent.toLowerCase().indexOf('firefox') > -1), 453 454 // http://stackoverflow.com/a/11752084/569101 455 isMac: (window.navigator.platform.toUpperCase().indexOf('MAC') >= 0), 456 457 // https://github.com/jashkenas/underscore 458 // Lonely letter MUST USE the uppercase code 459 keyCode: { 460 BACKSPACE: 8, 461 TAB: 9, 462 ENTER: 13, 463 ESCAPE: 27, 464 SPACE: 32, 465 DELETE: 46, 466 K: 75, // K keycode, and not k 467 M: 77, 468 V: 86 469 }, 470 471 /** 472 * Returns true if it's metaKey on Mac, or ctrlKey on non-Mac. 473 * See #591 474 */ 475 isMetaCtrlKey: function (event) { 476 if ((Util.isMac && event.metaKey) || (!Util.isMac && event.ctrlKey)) { 477 return true; 478 } 479 480 return false; 481 }, 482 483 /** 484 * Returns true if the key associated to the event is inside keys array 485 * 486 * @see : https://github.com/jquery/jquery/blob/0705be475092aede1eddae01319ec931fb9c65fc/src/event.js#L473-L484 487 * @see : http://stackoverflow.com/q/4471582/569101 488 */ 489 isKey: function (event, keys) { 490 var keyCode = Util.getKeyCode(event); 491 492 // it's not an array let's just compare strings! 493 if (false === Array.isArray(keys)) { 494 return keyCode === keys; 495 } 496 497 if (-1 === keys.indexOf(keyCode)) { 498 return false; 499 } 500 501 return true; 502 }, 503 504 getKeyCode: function (event) { 505 var keyCode = event.which; 506 507 // getting the key code from event 508 if (null === keyCode) { 509 keyCode = event.charCode !== null ? event.charCode : event.keyCode; 510 } 511 512 return keyCode; 513 }, 514 515 blockContainerElementNames: [ 516 // elements our editor generates 517 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'ul', 'li', 'ol', 518 // all other known block elements 519 'address', 'article', 'aside', 'audio', 'canvas', 'dd', 'dl', 'dt', 'fieldset', 520 'figcaption', 'figure', 'footer', 'form', 'header', 'hgroup', 'main', 'nav', 521 'noscript', 'output', 'section', 'video', 'div', 522 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td' 523 ], 524 525 emptyElementNames: ['br', 'col', 'colgroup', 'hr', 'img', 'input', 'source', 'wbr'], 526 527 extend: function extend(/* dest, source1, source2, ...*/) { 528 var args = [true].concat(Array.prototype.slice.call(arguments)); 529 return copyInto.apply(this, args); 530 }, 531 532 defaults: function defaults(/*dest, source1, source2, ...*/) { 533 var args = [false].concat(Array.prototype.slice.call(arguments)); 534 return copyInto.apply(this, args); 535 }, 536 537 /* 538 * Create a link around the provided text nodes which must be adjacent to each other and all be 539 * descendants of the same closest block container. If the preconditions are not met, unexpected 540 * behavior will result. 541 */ 542 createLink: function (document, textNodes, href, target) { 543 var anchor = document.createElement('a'); 544 Util.moveTextRangeIntoElement(textNodes[0], textNodes[textNodes.length - 1], anchor); 545 anchor.setAttribute('href', href); 546 if (target) { 547 anchor.setAttribute('target', target); 548 } 549 return anchor; 550 }, 551 552 /* 553 * Given the provided match in the format {start: 1, end: 2} where start and end are indices into the 554 * textContent of the provided element argument, modify the DOM inside element to ensure that the text 555 * identified by the provided match can be returned as text nodes that contain exactly that text, without 556 * any additional text at the beginning or end of the returned array of adjacent text nodes. 557 * 558 * The only DOM manipulation performed by this function is splitting the text nodes, non-text nodes are 559 * not affected in any way. 560 */ 561 findOrCreateMatchingTextNodes: function (document, element, match) { 562 var treeWalker = document.createTreeWalker(element, NodeFilter.SHOW_ALL, null, false), 563 matchedNodes = [], 564 currentTextIndex = 0, 565 startReached = false, 566 currentNode = null, 567 newNode = null; 568 569 while ((currentNode = treeWalker.nextNode()) !== null) { 570 if (currentNode.nodeType > 3) { 571 continue; 572 } else if (currentNode.nodeType === 3) { 573 if (!startReached && match.start < (currentTextIndex + currentNode.nodeValue.length)) { 574 startReached = true; 575 newNode = Util.splitStartNodeIfNeeded(currentNode, match.start, currentTextIndex); 576 } 577 if (startReached) { 578 Util.splitEndNodeIfNeeded(currentNode, newNode, match.end, currentTextIndex); 579 } 580 if (startReached && currentTextIndex === match.end) { 581 break; // Found the node(s) corresponding to the link. Break out and move on to the next. 582 } else if (startReached && currentTextIndex > (match.end + 1)) { 583 throw new Error('PerformLinking overshot the target!'); // should never happen... 584 } 585 586 if (startReached) { 587 matchedNodes.push(newNode || currentNode); 588 } 589 590 currentTextIndex += currentNode.nodeValue.length; 591 if (newNode !== null) { 592 currentTextIndex += newNode.nodeValue.length; 593 // Skip the newNode as we'll already have pushed it to the matches 594 treeWalker.nextNode(); 595 } 596 newNode = null; 597 } else if (currentNode.tagName.toLowerCase() === 'img') { 598 if (!startReached && (match.start <= currentTextIndex)) { 599 startReached = true; 600 } 601 if (startReached) { 602 matchedNodes.push(currentNode); 603 } 604 } 605 } 606 return matchedNodes; 607 }, 608 609 /* 610 * Given the provided text node and text coordinates, split the text node if needed to make it align 611 * precisely with the coordinates. 612 * 613 * This function is intended to be called from Util.findOrCreateMatchingTextNodes. 614 */ 615 splitStartNodeIfNeeded: function (currentNode, matchStartIndex, currentTextIndex) { 616 if (matchStartIndex !== currentTextIndex) { 617 return currentNode.splitText(matchStartIndex - currentTextIndex); 618 } 619 return null; 620 }, 621 622 /* 623 * Given the provided text node and text coordinates, split the text node if needed to make it align 624 * precisely with the coordinates. The newNode argument should from the result of Util.splitStartNodeIfNeeded, 625 * if that function has been called on the same currentNode. 626 * 627 * This function is intended to be called from Util.findOrCreateMatchingTextNodes. 628 */ 629 splitEndNodeIfNeeded: function (currentNode, newNode, matchEndIndex, currentTextIndex) { 630 var textIndexOfEndOfFarthestNode, 631 endSplitPoint; 632 textIndexOfEndOfFarthestNode = currentTextIndex + currentNode.nodeValue.length + 633 (newNode ? newNode.nodeValue.length : 0) - 1; 634 endSplitPoint = matchEndIndex - currentTextIndex - 635 (newNode ? currentNode.nodeValue.length : 0); 636 if (textIndexOfEndOfFarthestNode >= matchEndIndex && 637 currentTextIndex !== textIndexOfEndOfFarthestNode && 638 endSplitPoint !== 0) { 639 (newNode || currentNode).splitText(endSplitPoint); 640 } 641 }, 642 643 /* 644 * Take an element, and break up all of its text content into unique pieces such that: 645 * 1) All text content of the elements are in separate blocks. No piece of text content should span 646 * across multiple blocks. This means no element return by this function should have 647 * any blocks as children. 648 * 2) The union of the textcontent of all of the elements returned here covers all 649 * of the text within the element. 650 * 651 * 652 * EXAMPLE: 653 * In the event that we have something like: 654 * 655 * <blockquote> 656 * <p>Some Text</p>
657 * <ol> 658 * <li>List Item 1</li> 659 * <li>List Item 2</li> 660 * </ol> 661 * </blockquote> 662 * 663 * This function would return these elements as an array: 664 * [ <p>Some Text</p>, <li>List Item 1</li>, <li>List Item 2</li> ] 665 * 666 * Since the <blockquote> and <ol> elements contain blocks within them they are not returned. 667 * Since the <p> and <li>'s don't contain block elements and cover all the text content of the 668 * <blockquote> container, they are the elements returned. 669 */ 670 splitByBlockElements: function (element) { 671 if (element.nodeType !== 3 && element.nodeType !== 1) { 672 return []; 673 } 674 675 var toRet = [], 676 blockElementQuery = MediumEditor.util.blockContainerElementNames.join(','); 677 678 if (element.nodeType === 3 || element.querySelectorAll(blockElementQuery).length === 0) { 679 return [element]; 680 } 681 682 for (var i = 0; i < element.childNodes.length; i++) { 683 var child = element.childNodes[i]; 684 if (child.nodeType === 3) { 685 toRet.push(child); 686 } else if (child.nodeType === 1) { 687 var blockElements = child.querySelectorAll(blockElementQuery); 688 if (blockElements.length === 0) { 689 toRet.push(child); 690 } else { 691 toRet = toRet.concat(MediumEditor.util.splitByBlockElements(child)); 692 } 693 } 694 } 695 696 return toRet; 697 }, 698 699 // Find the next node in the DOM tree that represents any text that is being 700 // displayed directly next to the targetNode (passed as an argument) 701 // Text that appears directly next to the current node can be: 702 // - A sibling text node 703 // - A descendant of a sibling element 704 // - A sibling text node of an ancestor 705 // - A descendant of a sibling element of an ancestor 706 findAdjacentTextNodeWithContent: function findAdjacentTextNodeWithContent(rootNode, targetNode, ownerDocument) { 707 var pastTarget = false, 708 nextNode, 709 nodeIterator = ownerDocument.createNodeIterator(rootNode, NodeFilter.SHOW_TEXT, null, false); 710 711 // Use a native NodeIterator to iterate over all the text nodes that are descendants 712 // of the rootNode. Once past the targetNode, choose the first non-empty text node 713 nextNode = nodeIterator.nextNode(); 714 while (nextNode) { 715 if (nextNode === targetNode) { 716 pastTarget = true; 717 } else if (pastTarget) { 718 if (nextNode.nodeType === 3 && nextNode.nodeValue && nextNode.nodeValue.trim().length > 0) { 719 break; 720 } 721 } 722 nextNode = nodeIterator.nextNode(); 723 } 724 725 return nextNode; 726 }, 727 728 // Find an element's previous sibling within a medium-editor element 729 // If one doesn't exist, find the closest ancestor's previous sibling 730 findPreviousSibling: function (node) { 731 if (!node || Util.isMediumEditorElement(node)) { 732 return false; 733 } 734 735 var previousSibling = node.previousSibling; 736 while (!previousSibling && !Util.isMediumEditorElement(node.parentNode)) { 737 node = node.parentNode; 738 previousSibling = node.previousSibling; 739 } 740 741 return previousSibling; 742 }, 743 744 isDescendant: function isDescendant(parent, child, checkEquality) { 745 if (!parent || !child) { 746 return false; 747 } 748 if (parent === child) { 749 return !!checkEquality; 750 } 751 // If parent is not an element, it can't have any descendants 752 if (parent.nodeType !== 1) { 753 return false; 754 } 755 if (nodeContainsWorksWithTextNodes || child.nodeType !== 3) { 756 return parent.contains(child); 757 } 758 var node = child.parentNode; 759 while (node !== null) { 760 if (node === parent) { 761 return true; 762 } 763 node = node.parentNode; 764 } 765 return false; 766 }, 767 768 // https://github.com/jashkenas/underscore 769 isElement: function isElement(obj) { 770 return !!(obj && obj.nodeType === 1); 771 }, 772 773 // https://github.com/jashkenas/underscore 774 throttle: function (func, wait) { 775 var THROTTLE_INTERVAL = 50, 776 context, 777 args, 778 result, 779 timeout = null, 780 previous = 0, 781 later = function () { 782 previous = Date.now(); 783 timeout = null;
vendor: 4,978 bytes, lines 784-894
784 result = func.apply(context, args); 785 if (!timeout) { 786 context = args = null; 787 } 788 }; 789 790 if (!wait && wait !== 0) { 791 wait = THROTTLE_INTERVAL; 792 } 793 794 return function () { 795 var now = Date.now(), 796 remaining = wait - (now - previous); 797 798 context = this; 799 args = arguments; 800 if (remaining <= 0 || remaining > wait) { 801 if (timeout) { 802 clearTimeout(timeout); 803 timeout = null; 804 } 805 previous = now; 806 result = func.apply(context, args); 807 if (!timeout) { 808 context = args = null; 809 } 810 } else if (!timeout) { 811 timeout = setTimeout(later, remaining); 812 } 813 return result; 814 }; 815 }, 816 817 traverseUp: function (current, testElementFunction) { 818 if (!current) { 819 return false; 820 } 821 822 do { 823 if (current.nodeType === 1) { 824 if (testElementFunction(current)) { 825 return current; 826 } 827 // do not traverse upwards past the nearest containing editor 828 if (Util.isMediumEditorElement(current)) { 829 return false; 830 } 831 } 832 833 current = current.parentNode; 834 } while (current); 835 836 return false; 837 }, 838 839 htmlEntities: function (str) { 840 // converts special characters (like <) into their escaped/encoded values (like <). 841 // This allows you to show to display the string without the browser reading it as HTML. 842 return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 843 }, 844 845 // http://stackoverflow.com/questions/6690752/insert-html-at-caret-in-a-contenteditable-div 846 insertHTMLCommand: function (doc, html) { 847 var selection, range, el, fragment, node, lastNode, toReplace, 848 res = false, 849 ecArgs = ['insertHTML', false, html]; 850 851 /* Edge's implementation of insertHTML is just buggy right now: 852 * - Doesn't allow leading white space at the beginning of an element 853 * - Found a case when a <font size="2"> tag was inserted when calling alignCenter inside a blockquote 854 * 855 * There are likely other bugs, these are just the ones we found so far. 856 * For now, let's just use the same fallback we did for IE 857 */ 858 if (!MediumEditor.util.isEdge && doc.queryCommandSupported('insertHTML')) { 859 try { 860 return doc.execCommand.apply(doc, ecArgs); 861 } catch (ignore) {} 862 } 863 864 selection = doc.getSelection(); 865 if (selection.rangeCount) { 866 range = selection.getRangeAt(0); 867 toReplace = range.commonAncestorContainer; 868 869 // https://github.com/yabwe/medium-editor/issues/748 870 // If the selection is an empty editor element, create a temporary text node inside of the editor 871 // and select it so that we don't delete the editor element 872 if (Util.isMediumEditorElement(toReplace) && !toReplace.firstChild) { 873 range.selectNode(toReplace.appendChild(doc.createTextNode(''))); 874 } else if ((toReplace.nodeType === 3 && range.startOffset === 0 && range.endOffset === toReplace.nodeValue.length) || 875 (toReplace.nodeType !== 3 && toReplace.innerHTML === range.toString())) { 876 // Ensure range covers maximum amount of nodes as possible 877 // By moving up the DOM and selecting ancestors whose only child is the range 878 while (!Util.isMediumEditorElement(toReplace) && 879 toReplace.parentNode && 880 toReplace.parentNode.childNodes.length === 1 && 881 !Util.isMediumEditorElement(toReplace.parentNode)) { 882 toReplace = toReplace.parentNode; 883 } 884 range.selectNode(toReplace); 885 } 886 range.deleteContents(); 887 888 el = doc.createElement('div'); 889 el.innerHTML = html; 890 fragment = doc.createDocumentFragment(); 891 while (el.firstChild) { 892 node = el.firstChild; 893 lastNode = fragment.appendChild(node); 894 }
895 range.insertNode(fragment); 896 897 // Preserve the selection: 898 if (lastNode) { 899 range = range.cloneRange(); 900 range.setStartAfter(lastNode); 901 range.collapse(true); 902 MediumEditor.selection.selectRange(doc, range); 903 } 904 res = true; 905 } 906 907 // https://github.com/yabwe/medium-editor/issues/992 908 // If we're monitoring calls to execCommand, notify listeners as if a real call had happened 909 if (doc.execCommand.callListeners) { 910 doc.execCommand.callListeners(ecArgs, res); 911 } 912 return res; 913 }, 914 915 execFormatBlock: function (doc, tagName) { 916 // Get the top level block element that contains the selection 917 var blockContainer = Util.getTopBlockContainer(MediumEditor.selection.getSelectionStart(doc)), 918 childNodes; 919 920 // Special handling for blockquote 921 if (tagName === 'blockquote') { 922 if (blockContainer) { 923 childNodes = Array.prototype.slice.call(blockContainer.childNodes); 924 // Check if the blockquote has a block element as a child (nested blocks) 925 if (childNodes.some(function (childNode) { 926 return Util.isBlockContainer(childNode); 927 })) { 928 // FF handles blockquote differently on formatBlock 929 // allowing nesting, we need to use outdent 930 // https://developer.mozilla.org/en-US/docs/Rich-Text_Editing_in_Mozilla 931 return doc.execCommand('outdent', false, null); 932 } 933 } 934 935 // When IE blockquote needs to be called as indent 936 // http://stackoverflow.com/questions/1816223/rich-text-editor-with-blockquote-function/1821777#1821777 937 if (Util.isIE) { 938 return doc.execCommand('indent', false, tagName); 939 } 940 } 941 942 // If the blockContainer is already the element type being passed in 943 // treat it as 'undo' formatting and just convert it to a <p> 944 if (blockContainer && tagName === blockContainer.nodeName.toLowerCase()) { 945 tagName = 'p'; 946 } 947 948 // When IE we need to add <> to heading elements 949 // http://stackoverflow.com/questions/10741831/execcommand-formatblock-headings-in-ie 950 if (Util.isIE) { 951 tagName = '<' + tagName + '>'; 952 } 953 954 if (blockContainer) { 955 var blockTagName = blockContainer.nodeName.toLowerCase(); 956 957 if (tagName !== blockTagName) { 958 // Changing list items to something else breaks, remove list item first 959 if (blockTagName === 'ul') { 960 doc.execCommand('insertunorderedlist', false); 961 } 962 963 if (blockTagName === 'ol') { 964 doc.execCommand('insertorderedlist', false); 965 } 966 } 967 } 968 969 // When FF, IE and Edge, we have to handle blockquote node seperately as 'formatblock' does not work. 970 // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#Commands 971 if (blockContainer && blockContainer.nodeName.toLowerCase() === 'blockquote') { 972 // For IE, just use outdent 973 if (Util.isIE && tagName === '<p>') { 974 return doc.execCommand('outdent', false, tagName); 975 } 976 977 // For Firefox and Edge, make sure there's a nested block element before calling outdent 978 if ((Util.isFF || Util.isEdge) && tagName === 'p') { 979 childNodes = Array.prototype.slice.call(blockContainer.childNodes); 980 // If there are some non-block elements we need to wrap everything in a <p> before we outdent 981 if (childNodes.some(function (childNode) { 982 return !Util.isBlockContainer(childNode); 983 })) { 984 doc.execCommand('formatBlock', false, tagName); 985 } 986 return doc.execCommand('outdent', false, tagName); 987 } 988 } 989 990 return doc.execCommand('formatBlock', false, tagName); 991 }, 992 993 /** 994 * Set target to blank on the given el element 995 * 996 * TODO: not sure if this should be here 997 * 998 * When creating a link (using core -> createLink) the selection returned by Firefox will be the parent of the created link 999 * instead of the created link itself (as it is for Chrome for example), so we retrieve all "a" children to grab the good one by 1000 * using `anchorUrl` to ensure that we are adding target="_blank" on the good one. 1001 * This isn't a bulletproof solution anyway .. 1002 */
vendor: 4,846 bytes, lines 1003-1127
1003 setTargetBlank: function (el, anchorUrl) { 1004 var i, url = anchorUrl || false; 1005 if (el.nodeName.toLowerCase() === 'a') { 1006 el.target = '_blank'; 1007 } else { 1008 el = el.getElementsByTagName('a'); 1009 1010 for (i = 0; i < el.length; i += 1) { 1011 if (false === url || url === el[i].attributes.href.value) { 1012 el[i].target = '_blank'; 1013 } 1014 } 1015 } 1016 }, 1017 1018 /* 1019 * this function is called to explicitly remove the target='_blank' as FF holds on to _blank value even 1020 * after unchecking the checkbox on anchor form 1021 */ 1022 removeTargetBlank: function (el, anchorUrl) { 1023 var i; 1024 if (el.nodeName.toLowerCase() === 'a') { 1025 el.removeAttribute('target'); 1026 } else { 1027 el = el.getElementsByTagName('a'); 1028 1029 for (i = 0; i < el.length; i += 1) { 1030 if (anchorUrl === el[i].attributes.href.value) { 1031 el[i].removeAttribute('target'); 1032 } 1033 } 1034 } 1035 }, 1036 1037 /* 1038 * this function adds one or several classes on an a element. 1039 * if el parameter is not an a, it will look for a children of el. 1040 * if no a children are found, it will look for the a parent. 1041 */ 1042 addClassToAnchors: function (el, buttonClass) { 1043 var classes = buttonClass.split(' '), 1044 i, 1045 j; 1046 if (el.nodeName.toLowerCase() === 'a') { 1047 for (j = 0; j < classes.length; j += 1) { 1048 el.classList.add(classes[j]); 1049 } 1050 } else { 1051 var aChildren = el.getElementsByTagName('a'); 1052 if (aChildren.length === 0) { 1053 var parentAnchor = Util.getClosestTag(el, 'a'); 1054 el = parentAnchor ? [parentAnchor] : []; 1055 } else { 1056 el = aChildren; 1057 } 1058 for (i = 0; i < el.length; i += 1) { 1059 for (j = 0; j < classes.length; j += 1) { 1060 el[i].classList.add(classes[j]); 1061 } 1062 } 1063 } 1064 }, 1065 1066 isListItem: function (node) { 1067 if (!node) { 1068 return false; 1069 } 1070 if (node.nodeName.toLowerCase() === 'li') { 1071 return true; 1072 } 1073 1074 var parentNode = node.parentNode, 1075 tagName = parentNode.nodeName.toLowerCase(); 1076 while (tagName === 'li' || (!Util.isBlockContainer(parentNode) && tagName !== 'div')) { 1077 if (tagName === 'li') { 1078 return true; 1079 } 1080 parentNode = parentNode.parentNode; 1081 if (parentNode) { 1082 tagName = parentNode.nodeName.toLowerCase(); 1083 } else { 1084 return false; 1085 } 1086 } 1087 return false; 1088 }, 1089 1090 cleanListDOM: function (ownerDocument, element) { 1091 if (element.nodeName.toLowerCase() !== 'li') { 1092 return; 1093 } 1094 1095 var list = element.parentElement; 1096 1097 if (list.parentElement.nodeName.toLowerCase() === 'p') { // yes we need to clean up 1098 Util.unwrap(list.parentElement, ownerDocument); 1099 1100 // move cursor at the end of the text inside the list 1101 // for some unknown reason, the cursor is moved to end of the "visual" line 1102 MediumEditor.selection.moveCursor(ownerDocument, element.firstChild, element.firstChild.textContent.length); 1103 } 1104 }, 1105 1106 /* splitDOMTree 1107 * 1108 * Given a root element some descendant element, split the root element 1109 * into its own element containing the descendant element and all elements 1110 * on the left or right side of the descendant ('right' is default) 1111 * 1112 * example: 1113 * 1114 * <div> 1115 * / | \ 1116 * <span> <span> <span> 1117 * / \ / \ / \ 1118 * 1 2 3 4 5 6 1119 * 1120 * If I wanted to split this tree given the <div> as the root and "4" as the leaf 1121 * the result would be (the prime ' marks indicates nodes that are created as clones): 1122 * 1123 * SPLITTING OFF 'RIGHT' TREE SPLITTING OFF 'LEFT' TREE 1124 * 1125 * <div> <div>' <div>' <div> 1126 * / \ / \ / \ | 1127 * <span> <span> <span>' <span>
1127 <span> <span> <span> 1128 * / \ | | / \ /\ /\ /\ 1129 * 1 2 3 4 5 6 1 2 3 4 5 6 1130 * 1131 * The above example represents splitting off the 'right' or 'left' part of a tree, where 1132 * the <div>' would be returned as an element not appended to the DOM, and the <div> 1133 * would remain in place where it was 1134 * 1135 */ 1136 splitOffDOMTree: function (rootNode, leafNode, splitLeft) { 1137 var splitOnNode = leafNode, 1138 createdNode = null, 1139 splitRight = !splitLeft; 1140 1141 // loop until we hit the root 1142 while (splitOnNode !== rootNode) { 1143 var currParent = splitOnNode.parentNode, 1144 newParent = currParent.cloneNode(false), 1145 targetNode = (splitRight ? splitOnNode : currParent.firstChild), 1146 appendLast; 1147 1148 // Create a new parent element which is a clone of the current parent 1149 if (createdNode) { 1150 if (splitRight) { 1151 // If we're splitting right, add previous created element before siblings 1152 newParent.appendChild(createdNode); 1153 } else { 1154 // If we're splitting left, add previous created element last 1155 appendLast = createdNode; 1156 } 1157 } 1158 createdNode = newParent; 1159 1160 while (targetNode) { 1161 var sibling = targetNode.nextSibling; 1162 // Special handling for the 'splitNode' 1163 if (targetNode === splitOnNode) { 1164 if (!targetNode.hasChildNodes()) { 1165 targetNode.parentNode.removeChild(targetNode); 1166 } else { 1167 // For the node we're splitting on, if it has children, we need to clone it 1168 // and not just move it 1169 targetNode = targetNode.cloneNode(false); 1170 } 1171 // If the resulting split node has content, add it 1172 if (targetNode.textContent) { 1173 createdNode.appendChild(targetNode); 1174 } 1175 1176 targetNode = (splitRight ? sibling : null); 1177 } else { 1178 // For general case, just remove the element and only 1179 // add it to the split tree if it contains something 1180 targetNode.parentNode.removeChild(targetNode); 1181 if (targetNode.hasChildNodes() || targetNode.textContent) { 1182 createdNode.appendChild(targetNode); 1183 } 1184 1185 targetNode = sibling; 1186 } 1187 } 1188 1189 // If we had an element we wanted to append at the end, do that now 1190 if (appendLast) { 1191 createdNode.appendChild(appendLast); 1192 } 1193 1194 splitOnNode = currParent; 1195 } 1196 1197 return createdNode; 1198 }, 1199 1200 moveTextRangeIntoElement: function (startNode, endNode, newElement) { 1201 if (!startNode || !endNode) { 1202 return false; 1203 } 1204 1205 var rootNode = Util.findCommonRoot(startNode, endNode); 1206 if (!rootNode) { 1207 return false; 1208 } 1209 1210 if (endNode === startNode) { 1211 var temp = startNode.parentNode, 1212 sibling = startNode.nextSibling; 1213 temp.removeChild(startNode); 1214 newElement.appendChild(startNode); 1215 if (sibling) { 1216 temp.insertBefore(newElement, sibling); 1217 } else { 1218 temp.appendChild(newElement); 1219 } 1220 return newElement.hasChildNodes(); 1221 } 1222 1223 // create rootChildren array which includes all the children 1224 // we care about 1225 var rootChildren = [], 1226 firstChild, 1227 lastChild, 1228 nextNode; 1229 for (var i = 0; i < rootNode.childNodes.length; i++) { 1230 nextNode = rootNode.childNodes[i];
vendor: 264,467 bytes, lines 1231-7676
1231 if (!firstChild) { 1232 if (Util.isDescendant(nextNode, startNode, true)) { 1233 firstChild = nextNode; 1234 } 1235 } else { 1236 if (Util.isDescendant(nextNode, endNode, true)) { 1237 lastChild = nextNode; 1238 break; 1239 } else { 1240 rootChildren.push(nextNode); 1241 } 1242 } 1243 } 1244 1245 var afterLast = lastChild.nextSibling, 1246 fragment = rootNode.ownerDocument.createDocumentFragment(); 1247 1248 // build up fragment on startNode side of tree 1249 if (firstChild === startNode) { 1250 firstChild.parentNode.removeChild(firstChild); 1251 fragment.appendChild(firstChild); 1252 } else { 1253 fragment.appendChild(Util.splitOffDOMTree(firstChild, startNode)); 1254 } 1255 1256 // add any elements between firstChild & lastChild 1257 rootChildren.forEach(function (element) { 1258 element.parentNode.removeChild(element); 1259 fragment.appendChild(element); 1260 }); 1261 1262 // build up fragment on endNode side of the tree 1263 if (lastChild === endNode) { 1264 lastChild.parentNode.removeChild(lastChild); 1265 fragment.appendChild(lastChild); 1266 } else { 1267 fragment.appendChild(Util.splitOffDOMTree(lastChild, endNode, true)); 1268 } 1269 1270 // Add fragment into passed in element 1271 newElement.appendChild(fragment); 1272 1273 if (lastChild.parentNode === rootNode) { 1274 // If last child is in the root, insert newElement in front of it 1275 rootNode.insertBefore(newElement, lastChild); 1276 } else if (afterLast) { 1277 // If last child was removed, but it had a sibling, insert in front of it 1278 rootNode.insertBefore(newElement, afterLast); 1279 } else { 1280 // lastChild was removed and was the last actual element just append 1281 rootNode.appendChild(newElement); 1282 } 1283 1284 return newElement.hasChildNodes(); 1285 }, 1286 1287 /* based on http://stackoverflow.com/a/6183069 */ 1288 depthOfNode: function (inNode) { 1289 var theDepth = 0, 1290 node = inNode; 1291 while (node.parentNode !== null) { 1292 node = node.parentNode; 1293 theDepth++; 1294 } 1295 return theDepth; 1296 }, 1297 1298 findCommonRoot: function (inNode1, inNode2) { 1299 var depth1 = Util.depthOfNode(inNode1), 1300 depth2 = Util.depthOfNode(inNode2), 1301 node1 = inNode1, 1302 node2 = inNode2; 1303 1304 while (depth1 !== depth2) { 1305 if (depth1 > depth2) { 1306 node1 = node1.parentNode; 1307 depth1 -= 1; 1308 } else { 1309 node2 = node2.parentNode; 1310 depth2 -= 1; 1311 } 1312 } 1313 1314 while (node1 !== node2) { 1315 node1 = node1.parentNode; 1316 node2 = node2.parentNode; 1317 } 1318 1319 return node1; 1320 }, 1321 /* END - based on http://stackoverflow.com/a/6183069 */ 1322 1323 isElementAtBeginningOfBlock: function (node) { 1324 var textVal, 1325 sibling; 1326 while (!Util.isBlockContainer(node) && !Util.isMediumEditorElement(node)) { 1327 sibling = node; 1328 while (sibling = sibling.previousSibling) { 1329 textVal = sibling.nodeType === 3 ? sibling.nodeValue : sibling.textContent; 1330 if (textVal.length > 0) { 1331 return false; 1332 } 1333 } 1334 node = node.parentNode; 1335 } 1336 return true; 1337 }, 1338 1339 isMediumEditorElement: function (element) { 1340 return element && element.getAttribute && !!element.getAttribute('data-medium-editor-element'); 1341 }, 1342 1343 getContainerEditorElement: function (element) { 1344 return Util.traverseUp(element, function (node) { 1345 return Util.isMediumEditorElement(node); 1346 }); 1347 }, 1348 1349 isBlockContainer: function (element) { 1350 return element && element.nodeType !== 3 && !Util.isMediumEditorElement(element) && Util.blockContainerElementNames.indexOf(element.nodeName.toLowerCase()) !== -1; 1351 }, 1352 1353 /* Finds the closest ancestor which is a block container element 1354 * If element is within editor element but not within any other block element, 1355 * the editor element is returned 1356 */ 1357 getClosestBlockContainer: function (node) { 1358 return Util.traverseUp(node, function (node) { 1359 return Util.isBlockContainer(node) || Util.isMediumEditorElement(node); 1360 }); 1361 }, 1362 1363 /* Finds highest level ancestor element which is a block container element 1364 * If element is within editor element but not within any other block element, 1365 * the editor element is returned 1366 */ 1367 getTopBlockContainer: function (element) { 1368 var topBlock = Util.isBlockContainer(element) ? element : false; 1369 Util.traverseUp(element, function (el) { 1370 if (Util.isBlockContainer(el)) { 1371 topBlock = el; 1372 } 1373 if (!topBlock && Util.isMediumEditorElement(el)) { 1374 topBlock = el; 1375 return true; 1376 } 1377 return false; 1378 }); 1379 return topBlock; 1380 }, 1381 1382 getFirstSelectableLeafNode: function (element) { 1383 while (element && element.firstChild) { 1384 element = element.firstChild; 1385 } 1386 1387 // We don't want to set the selection to an element that can't have children, this messes up Gecko. 1388 element = Util.traverseUp(element, function (el) { 1389 return Util.emptyElementNames.indexOf(el.nodeName.toLowerCase()) === -1; 1390 }); 1391 // Selecting at the beginning of a table doesn't work in PhantomJS. 1392 if (element.nodeName.toLowerCase() === 'table') { 1393 var firstCell = element.querySelector('th, td'); 1394 if (firstCell) { 1395 element = firstCell; 1396 } 1397 } 1398 return element; 1399 }, 1400 1401 // TODO: remove getFirstTextNode AND _getFirstTextNode when jumping in 6.0.0 (no code references) 1402 getFirstTextNode: function (element) { 1403 Util.warn('getFirstTextNode is deprecated and will be removed in version 6.0.0'); 1404 return Util._getFirstTextNode(element); 1405 }, 1406 1407 _getFirstTextNode: function (element) { 1408 if (element.nodeType === 3) { 1409 return element; 1410 } 1411 1412 for (var i = 0; i < element.childNodes.length; i++) { 1413 var textNode = Util._getFirstTextNode(element.childNodes[i]); 1414 if (textNode !== null) { 1415 return textNode; 1416 } 1417 } 1418 return null; 1419 }, 1420 1421 ensureUrlHasProtocol: function (url) { 1422 if (url.indexOf('://') === -1) { 1423 return 'http://' + url; 1424 } 1425 return url; 1426 }, 1427 1428 warn: function () { 1429 if (window.console !== undefined && typeof window.console.warn === 'function') { 1430 window.console.warn.apply(window.console, arguments); 1431 } 1432 }, 1433 1434 deprecated: function (oldName, newName, version) { 1435 // simple deprecation warning mechanism. 1436 var m = oldName + ' is deprecated, please use ' + newName + ' instead.'; 1437 if (version) { 1438 m += ' Will be removed in ' + version; 1439 } 1440 Util.warn(m); 1441 }, 1442 1443 deprecatedMethod: function (oldName, newName, args, version) { 1444 // run the replacement and warn when someone calls a deprecated method 1445 Util.deprecated(oldName, newName, version); 1446 if (typeof this[newName] === 'function') { 1447 this[newName].apply(this, args); 1448 } 1449 }, 1450 1451 cleanupAttrs: function (el, attrs) { 1452 attrs.forEach(function (attr) { 1453 el.removeAttribute(attr); 1454 }); 1455 }, 1456 1457 cleanupTags: function (el, tags) { 1458 if (tags.indexOf(el.nodeName.toLowerCase()) !== -1) { 1459 el.parentNode.removeChild(el); 1460 } 1461 }, 1462 1463 unwrapTags: function (el, tags) { 1464 if (tags.indexOf(el.nodeName.toLowerCase()) !== -1) { 1465 MediumEditor.util.unwrap(el, document); 1466 } 1467 }, 1468 1469 // get the closest parent 1470 getClosestTag: function (el, tag) { 1471 return Util.traverseUp(el, function (element) { 1472 return element.nodeName.toLowerCase() === tag.toLowerCase(); 1473 }); 1474 }, 1475 1476 unwrap: function (el, doc) { 1477 var fragment = doc.createDocumentFragment(), 1478 nodes = Array.prototype.slice.call(el.childNodes); 1479 1480 // cast nodeList to array since appending child 1481 // to a different node will alter length of el.childNodes 1482 for (var i = 0; i < nodes.length; i++) { 1483 fragment.appendChild(nodes[i]); 1484 } 1485 1486 if (fragment.childNodes.length) { 1487 el.parentNode.replaceChild(fragment, el); 1488 } else { 1489 el.parentNode.removeChild(el); 1490 } 1491 }, 1492 1493 guid: function () { 1494 function _s4() { 1495 return Math 1496 .floor((1 + Math.random()) * 0x10000) 1497 .toString(16) 1498 .substring(1); 1499 } 1500 1501 return _s4() + _s4() + '-' + _s4() + '-' + _s4() + '-' + _s4() + '-' + _s4() + _s4() + _s4(); 1502 } 1503 }; 1504 1505 MediumEditor.util = Util; 1506}(window)); 1507 1508(function () { 1509 'use strict'; 1510 1511 var Extension = function (options) { 1512 MediumEditor.util.extend(this, options); 1513 }; 1514 1515 Extension.extend = function (protoProps) { 1516 // magic extender thinger. mostly borrowed from backbone/goog.inherits 1517 // place this function on some thing you want extend-able. 1518 // 1519 // example: 1520 // 1521 // function Thing(args){ 1522 // this.options = args; 1523 // } 1524 // 1525 // Thing.prototype = { foo: "bar" }; 1526 // Thing.extend = extenderify; 1527 // 1528 // var ThingTwo = Thing.extend({ foo: "baz" }); 1529 // 1530 // var thingOne = new Thing(); // foo === "bar" 1531 // var thingTwo = new ThingTwo(); // foo === "baz" 1532 // 1533 // which seems like some simply shallow copy nonsense 1534 // at first, but a lot more is going on there. 1535 // 1536 // passing a `constructor` to the extend props 1537 // will cause the instance to instantiate through that 1538 // instead of the parent's constructor. 1539 1540 var parent = this, 1541 child; 1542 1543 // The constructor function for the new subclass is either defined by you 1544 // (the "constructor" property in your `extend` definition), or defaulted 1545 // by us to simply call the parent's constructor. 1546 1547 if (protoProps && protoProps.hasOwnProperty('constructor')) { 1548 child = protoProps.constructor; 1549 } else { 1550 child = function () { 1551 return parent.apply(this, arguments); 1552 }; 1553 } 1554 1555 // das statics (.extend comes over, so your subclass can have subclasses too) 1556 MediumEditor.util.extend(child, parent); 1557 1558 // Set the prototype chain to inherit from `parent`, without calling 1559 // `parent`'s constructor function. 1560 var Surrogate = function () { 1561 this.constructor = child; 1562 }; 1563 Surrogate.prototype = parent.prototype; 1564 child.prototype = new Surrogate(); 1565 1566 if (protoProps) { 1567 MediumEditor.util.extend(child.prototype, protoProps); 1568 } 1569 1570 // todo: $super? 1571 1572 return child; 1573 }; 1574 1575 Extension.prototype = { 1576 /* init: [function] 1577 * 1578 * Called by MediumEditor during initialization. 1579 * The .base property will already have been set to 1580 * current instance of MediumEditor when this is called. 1581 * All helper methods will exist as well 1582 */ 1583 init: function () {}, 1584 1585 /* base: [MediumEditor instance] 1586 * 1587 * If not overriden, this will be set to the current instance 1588 * of MediumEditor, before the init method is called 1589 */ 1590 base: undefined, 1591 1592 /* name: [string] 1593 * 1594 * 'name' of the extension, used for retrieving the extension. 1595 * If not set, MediumEditor will set this to be the key 1596 * used when passing the extension into MediumEditor via the 1597 * 'extensions' option 1598 */ 1599 name: undefined, 1600 1601 /* checkState: [function (node)] 1602 * 1603 * If implemented, this function will be called one or more times 1604 * the state of the editor & toolbar are updated. 1605 * When the state is updated, the editor does the following: 1606 * 1607 * 1) Find the parent node containing the current selection 1608 * 2) Call checkState on the extension, passing the node as an argument 1609 * 3) Get the parent node of the previous node 1610 * 4) Repeat steps #2 and #3 until we move outside the parent contenteditable 1611 */ 1612 checkState: undefined, 1613 1614 /* destroy: [function ()] 1615 * 1616 * This method should remove any created html, custom event handlers 1617 * or any other cleanup tasks that should be performed. 1618 * If implemented, this function will be called when MediumEditor's 1619 * destroy method has been called. 1620 */ 1621 destroy: undefined, 1622 1623 /* As alternatives to checkState, these functions provide a more structured 1624 * path to updating the state of an extension (usually a button) whenever 1625 * the state of the editor & toolbar are updated. 1626 */ 1627 1628 /* queryCommandState: [function ()] 1629 * 1630 * If implemented, this function will be called once on each extension 1631 * when the state of the editor/toolbar is being updated. 1632 * 1633 * If this function returns a non-null value, the extension will 1634 * be ignored as the code climbs the dom tree. 1635 * 1636 * If this function returns true, and the setActive() function is defined 1637 * setActive() will be called 1638 */ 1639 queryCommandState: undefined, 1640 1641 /* isActive: [function ()] 1642 * 1643 * If implemented, this function will be called when MediumEditor 1644 * has determined that this extension is 'active' for the current selection. 1645 * This may be called when the editor & toolbar are being updated, 1646 * but only if queryCommandState() or isAlreadyApplied() functions 1647 * are implemented, and when called, return true. 1648 */ 1649 isActive: undefined, 1650 1651 /* isAlreadyApplied: [function (node)] 1652 * 1653 * If implemented, this function is similar to checkState() in 1654 * that it will be called repeatedly as MediumEditor moves up 1655 * the DOM to update the editor & toolbar after a state change. 1656 * 1657 * NOTE: This function will NOT be called if checkState() has 1658 * been implemented. This function will NOT be called if 1659 * queryCommandState() is implemented and returns a non-null 1660 * value when called 1661 */ 1662 isAlreadyApplied: undefined, 1663 1664 /* setActive: [function ()] 1665 * 1666 * If implemented, this function is called when MediumEditor knows 1667 * that this extension is currently enabled. Currently, this 1668 * function is called when updating the editor & toolbar, and 1669 * only if queryCommandState() or isAlreadyApplied(node) return 1670 * true when called 1671 */ 1672 setActive: undefined, 1673 1674 /* setInactive: [function ()] 1675 * 1676 * If implemented, this function is called when MediumEditor knows 1677 * that this extension is currently disabled. Curently, this 1678 * is called at the beginning of each state change for 1679 * the editor & toolbar. After calling this, MediumEditor 1680 * will attempt to update the extension, either via checkState() 1681 * or the combination of queryCommandState(), isAlreadyApplied(node), 1682 * isActive(), and setActive() 1683 */ 1684 setInactive: undefined, 1685 1686 /* getInteractionElements: [function ()] 1687 * 1688 * If the extension renders any elements that the user can interact with, 1689 * this method should be implemented and return the root element or an array 1690 * containing all of the root elements. MediumEditor will call this function 1691 * during interaction to see if the user clicked on something outside of the editor. 1692 * The elements are used to check if the target element of a click or 1693 * other user event is a descendant of any extension elements. 1694 * This way, the editor can also count user interaction within editor elements as 1695 * interactions with the editor, and thus not trigger 'blur' 1696 */ 1697 getInteractionElements: undefined, 1698 1699 /************************ Helpers ************************ 1700 * The following are helpers that are either set by MediumEditor 1701 * during initialization, or are helper methods which either 1702 * route calls to the MediumEditor instance or provide common 1703 * functionality for all extensions 1704 *********************************************************/ 1705 1706 /* window: [Window] 1707 * 1708 * If not overriden, this will be set to the window object 1709 * to be used by MediumEditor and its extensions. This is 1710 * passed via the 'contentWindow' option to MediumEditor 1711 * and is the global 'window' object by default 1712 */ 1713 'window': undefined, 1714 1715 /* document: [Document] 1716 * 1717 * If not overriden, this will be set to the document object 1718 * to be used by MediumEditor and its extensions. This is 1719 * passed via the 'ownerDocument' optin to MediumEditor 1720 * and is the global 'document' object by default 1721 */ 1722 'document': undefined, 1723 1724 /* getEditorElements: [function ()] 1725 * 1726 * Helper function which returns an array containing 1727 * all the contenteditable elements for this instance 1728 * of MediumEditor 1729 */ 1730 getEditorElements: function () { 1731 return this.base.elements; 1732 }, 1733 1734 /* getEditorId: [function ()] 1735 * 1736 * Helper function which returns a unique identifier 1737 * for this instance of MediumEditor 1738 */ 1739 getEditorId: function () { 1740 return this.base.id; 1741 }, 1742 1743 /* getEditorOptions: [function (option)] 1744 * 1745 * Helper function which returns the value of an option 1746 * used to initialize this instance of MediumEditor 1747 */ 1748 getEditorOption: function (option) { 1749 return this.base.options[option]; 1750 } 1751 }; 1752 1753 /* List of method names to add to the prototype of Extension 1754 * Each of these methods will be defined as helpers that 1755 * just call directly into the MediumEditor instance. 1756 * 1757 * example for 'on' method: 1758 * Extension.prototype.on = function () { 1759 * return this.base.on.apply(this.base, arguments); 1760 * } 1761 */ 1762 [ 1763 // general helpers 1764 'execAction', 1765 1766 // event handling 1767 'on', 1768 'off', 1769 'subscribe', 1770 'trigger' 1771 1772 ].forEach(function (helper) { 1773 Extension.prototype[helper] = function () { 1774 return this.base[helper].apply(this.base, arguments); 1775 }; 1776 }); 1777 1778 MediumEditor.Extension = Extension; 1779})(); 1780 1781(function () { 1782 'use strict'; 1783 1784 function filterOnlyParentElements(node) { 1785 if (MediumEditor.util.isBlockContainer(node)) { 1786 return NodeFilter.FILTER_ACCEPT; 1787 } else { 1788 return NodeFilter.FILTER_SKIP; 1789 } 1790 } 1791 1792 var Selection = { 1793 findMatchingSelectionParent: function (testElementFunction, contentWindow) { 1794 var selection = contentWindow.getSelection(), 1795 range, 1796 current; 1797 1798 if (selection.rangeCount === 0) { 1799 return false; 1800 } 1801 1802 range = selection.getRangeAt(0); 1803 current = range.commonAncestorContainer; 1804 1805 return MediumEditor.util.traverseUp(current, testElementFunction); 1806 }, 1807 1808 getSelectionElement: function (contentWindow) { 1809 return this.findMatchingSelectionParent(function (el) { 1810 return MediumEditor.util.isMediumEditorElement(el); 1811 }, contentWindow); 1812 }, 1813 1814 // http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html 1815 // Tim Down 1816 exportSelection: function (root, doc) { 1817 if (!root) { 1818 return null; 1819 } 1820 1821 var selectionState = null, 1822 selection = doc.getSelection(); 1823 1824 if (selection.rangeCount > 0) { 1825 var range = selection.getRangeAt(0), 1826 preSelectionRange = range.cloneRange(), 1827 start; 1828 1829 preSelectionRange.selectNodeContents(root); 1830 preSelectionRange.setEnd(range.startContainer, range.startOffset); 1831 start = preSelectionRange.toString().length; 1832 1833 selectionState = { 1834 start: start, 1835 end: start + range.toString().length 1836 }; 1837 1838 // Check to see if the selection starts with any images 1839 // if so we need to make sure the the beginning of the selection is 1840 // set correctly when importing selection 1841 if (this.doesRangeStartWithImages(range, doc)) { 1842 selectionState.startsWithImage = true; 1843 } 1844 1845 // Check to see if the selection has any trailing images 1846 // if so, this this means we need to look for them when we import selection 1847 var trailingImageCount = this.getTrailingImageCount(root, selectionState, range.endContainer, range.endOffset); 1848 if (trailingImageCount) { 1849 selectionState.trailingImageCount = trailingImageCount; 1850 } 1851 1852 // If start = 0 there may still be an empty paragraph before it, but we don't care. 1853 if (start !== 0) { 1854 var emptyBlocksIndex = this.getIndexRelativeToAdjacentEmptyBlocks(doc, root, range.startContainer, range.startOffset); 1855 if (emptyBlocksIndex !== -1) { 1856 selectionState.emptyBlocksIndex = emptyBlocksIndex; 1857 } 1858 } 1859 } 1860 1861 return selectionState; 1862 }, 1863 1864 // http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html 1865 // Tim Down 1866 // 1867 // {object} selectionState - the selection to import 1868 // {DOMElement} root - the root element the selection is being restored inside of 1869 // {Document} doc - the document to use for managing selection 1870 // {boolean} [favorLaterSelectionAnchor] - defaults to false. If true, import the cursor immediately 1871 // subsequent to an anchor tag if it would otherwise be placed right at the trailing edge inside the 1872 // anchor. This cursor positioning, even though visually equivalent to the user, can affect behavior 1873 // in MS IE. 1874 importSelection: function (selectionState, root, doc, favorLaterSelectionAnchor) { 1875 if (!selectionState || !root) { 1876 return; 1877 } 1878 1879 var range = doc.createRange(); 1880 range.setStart(root, 0); 1881 range.collapse(true); 1882 1883 var node = root, 1884 nodeStack = [], 1885 charIndex = 0, 1886 foundStart = false, 1887 foundEnd = false, 1888 trailingImageCount = 0, 1889 stop = false, 1890 nextCharIndex, 1891 allowRangeToStartAtEndOfNode = false, 1892 lastTextNode = null; 1893 1894 // When importing selection, the start of the selection may lie at the end of an element 1895 // or at the beginning of an element. Since visually there is no difference between these 2 1896 // we will try to move the selection to the beginning of an element since this is generally 1897 // what users will expect and it's a more predictable behavior. 1898 // 1899 // However, there are some specific cases when we don't want to do this: 1900 // 1) We're attempting to move the cursor outside of the end of an anchor [favorLaterSelectionAnchor = true] 1901 // 2) The selection starts with an image, which is special since an image doesn't have any 'content' 1902 // as far as selection and ranges are concerned 1903 // 3) The selection starts after a specified number of empty block elements (selectionState.emptyBlocksIndex) 1904 // 1905 // For these cases, we want the selection to start at a very specific location, so we should NOT 1906 // automatically move the cursor to the beginning of the first actual chunk of text 1907 if (favorLaterSelectionAnchor || selectionState.startsWithImage || typeof selectionState.emptyBlocksIndex !== 'undefined') { 1908 allowRangeToStartAtEndOfNode = true; 1909 } 1910 1911 while (!stop && node) { 1912 // Only iterate over elements and text nodes 1913 if (node.nodeType > 3) { 1914 node = nodeStack.pop(); 1915 continue; 1916 } 1917 1918 // If we hit a text node, we need to add the amount of characters to the overall count 1919 if (node.nodeType === 3 && !foundEnd) { 1920 nextCharIndex = charIndex + node.length; 1921 // Check if we're at or beyond the start of the selection we're importing 1922 if (!foundStart && selectionState.start >= charIndex && selectionState.start <= nextCharIndex) { 1923 // NOTE: We only want to allow a selection to start at the END of an element if 1924 // allowRangeToStartAtEndOfNode is true 1925 if (allowRangeToStartAtEndOfNode || selectionState.start < nextCharIndex) { 1926 range.setStart(node, selectionState.start - charIndex); 1927 foundStart = true; 1928 } 1929 // We're at the end of a text node where the selection could start but we shouldn't 1930 // make the selection start here because allowRangeToStartAtEndOfNode is false. 1931 // However, we should keep a reference to this node in case there aren't any more 1932 // text nodes after this, so that we have somewhere to import the selection to 1933 else { 1934 lastTextNode = node; 1935 } 1936 } 1937 // We've found the start of the selection, check if we're at or beyond the end of the selection we're importing 1938 if (foundStart && selectionState.end >= charIndex && selectionState.end <= nextCharIndex) { 1939 if (!selectionState.trailingImageCount) { 1940 range.setEnd(node, selectionState.end - charIndex); 1941 stop = true; 1942 } else { 1943 foundEnd = true; 1944 } 1945 } 1946 charIndex = nextCharIndex; 1947 } else { 1948 if (selectionState.trailingImageCount && foundEnd) { 1949 if (node.nodeName.toLowerCase() === 'img') { 1950 trailingImageCount++; 1951 } 1952 if (trailingImageCount === selectionState.trailingImageCount) { 1953 // Find which index the image is in its parent's children 1954 var endIndex = 0; 1955 while (node.parentNode.childNodes[endIndex] !== node) { 1956 endIndex++; 1957 } 1958 range.setEnd(node.parentNode, endIndex + 1); 1959 stop = true; 1960 } 1961 } 1962 1963 if (!stop && node.nodeType === 1) { 1964 // this is an element 1965 // add all its children to the stack 1966 var i = node.childNodes.length - 1; 1967 while (i >= 0) { 1968 nodeStack.push(node.childNodes[i]); 1969 i -= 1; 1970 } 1971 } 1972 } 1973 1974 if (!stop) { 1975 node = nodeStack.pop(); 1976 } 1977 } 1978 1979 // If we've gone through the entire text but didn't find the beginning of a text node 1980 // to make the selection start at, we should fall back to starting the selection 1981 // at the END of the last text node we found 1982 if (!foundStart && lastTextNode) { 1983 range.setStart(lastTextNode, lastTextNode.length); 1984 range.setEnd(lastTextNode, lastTextNode.length); 1985 } 1986 1987 if (typeof selectionState.emptyBlocksIndex !== 'undefined') { 1988 range = this.importSelectionMoveCursorPastBlocks(doc, root, selectionState.emptyBlocksIndex, range); 1989 } 1990 1991 // If the selection is right at the ending edge of a link, put it outside the anchor tag instead of inside. 1992 if (favorLaterSelectionAnchor) { 1993 range = this.importSelectionMoveCursorPastAnchor(selectionState, range); 1994 } 1995 1996 this.selectRange(doc, range); 1997 }, 1998 1999 // Utility method called from importSelection only 2000 importSelectionMoveCursorPastAnchor: function (selectionState, range) { 2001 var nodeInsideAnchorTagFunction = function (node) { 2002 return node.nodeName.toLowerCase() === 'a'; 2003 }; 2004 if (selectionState.start === selectionState.end && 2005 range.startContainer.nodeType === 3 && 2006 range.startOffset === range.startContainer.nodeValue.length && 2007 MediumEditor.util.traverseUp(range.startContainer, nodeInsideAnchorTagFunction)) { 2008 var prevNode = range.startContainer, 2009 currentNode = range.startContainer.parentNode; 2010 while (currentNode !== null && currentNode.nodeName.toLowerCase() !== 'a') { 2011 if (currentNode.childNodes[currentNode.childNodes.length - 1] !== prevNode) { 2012 currentNode = null; 2013 } else { 2014 prevNode = currentNode; 2015 currentNode = currentNode.parentNode; 2016 } 2017 } 2018 if (currentNode !== null && currentNode.nodeName.toLowerCase() === 'a') { 2019 var currentNodeIndex = null; 2020 for (var i = 0; currentNodeIndex === null && i < currentNode.parentNode.childNodes.length; i++) { 2021 if (currentNode.parentNode.childNodes[i] === currentNode) { 2022 currentNodeIndex = i; 2023 } 2024 } 2025 range.setStart(currentNode.parentNode, currentNodeIndex + 1); 2026 range.collapse(true); 2027 } 2028 } 2029 return range; 2030 }, 2031 2032 // Uses the emptyBlocksIndex calculated by getIndexRelativeToAdjacentEmptyBlocks 2033 // to move the cursor back to the start of the correct paragraph 2034 importSelectionMoveCursorPastBlocks: function (doc, root, index, range) { 2035 var treeWalker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filterOnlyParentElements, false), 2036 startContainer = range.startContainer, 2037 startBlock, 2038 targetNode, 2039 currIndex = 0; 2040 index = index || 1; // If index is 0, we still want to move to the next block 2041 2042 // Chrome counts newlines and spaces that separate block elements as actual elements. 2043 // If the selection is inside one of these text nodes, and it has a previous sibling 2044 // which is a block element, we want the treewalker to start at the previous sibling 2045 // and NOT at the parent of the textnode 2046 if (startContainer.nodeType === 3 && MediumEditor.util.isBlockContainer(startContainer.previousSibling)) { 2047 startBlock = startContainer.previousSibling; 2048 } else { 2049 startBlock = MediumEditor.util.getClosestBlockContainer(startContainer); 2050 } 2051 2052 // Skip over empty blocks until we hit the block we want the selection to be in 2053 while (treeWalker.nextNode()) { 2054 if (!targetNode) { 2055 // Loop through all blocks until we hit the starting block element 2056 if (startBlock === treeWalker.currentNode) { 2057 targetNode = treeWalker.currentNode; 2058 } 2059 } else { 2060 targetNode = treeWalker.currentNode; 2061 currIndex++; 2062 // We hit the target index, bail 2063 if (currIndex === index) { 2064 break; 2065 } 2066 // If we find a non-empty block, ignore the emptyBlocksIndex and just put selection here 2067 if (targetNode.textContent.length > 0) { 2068 break; 2069 } 2070 } 2071 } 2072 2073 if (!targetNode) { 2074 targetNode = startBlock; 2075 } 2076 2077 // We're selecting a high-level block node, so make sure the cursor gets moved into the deepest 2078 // element at the beginning of the block 2079 range.setStart(MediumEditor.util.getFirstSelectableLeafNode(targetNode), 0); 2080 2081 return range; 2082 }, 2083 2084 // Returns -1 unless the cursor is at the beginning of a paragraph/block 2085 // If the paragraph/block is preceeded by empty paragraphs/block (with no text) 2086 // it will return the number of empty paragraphs before the cursor. 2087 // Otherwise, it will return 0, which indicates the cursor is at the beginning 2088 // of a paragraph/block, and not at the end of the paragraph/block before it 2089 getIndexRelativeToAdjacentEmptyBlocks: function (doc, root, cursorContainer, cursorOffset) { 2090 // If there is text in front of the cursor, that means there isn't only empty blocks before it 2091 if (cursorContainer.textContent.length > 0 && cursorOffset > 0) { 2092 return -1; 2093 } 2094 2095 // Check if the block that contains the cursor has any other text in front of the cursor 2096 var node = cursorContainer; 2097 if (node.nodeType !== 3) { 2098 node = cursorContainer.childNodes[cursorOffset]; 2099 } 2100 if (node) { 2101 // The element isn't at the beginning of a block, so it has content before it 2102 if (!MediumEditor.util.isElementAtBeginningOfBlock(node)) { 2103 return -1; 2104 } 2105 2106 var previousSibling = MediumEditor.util.findPreviousSibling(node); 2107 // If there is no previous sibling, this is the first text element in the editor 2108 if (!previousSibling) { 2109 return -1; 2110 } 2111 // If the previous sibling has text, then there are no empty blocks before this 2112 else if (previousSibling.nodeValue) { 2113 return -1; 2114 } 2115 } 2116 2117 // Walk over block elements, counting number of empty blocks between last piece of text 2118 // and the block the cursor is in 2119 var closestBlock = MediumEditor.util.getClosestBlockContainer(cursorContainer), 2120 treeWalker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, filterOnlyParentElements, false), 2121 emptyBlocksCount = 0; 2122 while (treeWalker.nextNode()) { 2123 var blockIsEmpty = treeWalker.currentNode.textContent === ''; 2124 if (blockIsEmpty || emptyBlocksCount > 0) { 2125 emptyBlocksCount += 1; 2126 } 2127 if (treeWalker.currentNode === closestBlock) { 2128 return emptyBlocksCount; 2129 } 2130 if (!blockIsEmpty) { 2131 emptyBlocksCount = 0; 2132 } 2133 } 2134 2135 return emptyBlocksCount; 2136 }, 2137 2138 // Returns true if the selection range begins with an image tag 2139 // Returns false if the range starts with any non empty text nodes 2140 doesRangeStartWithImages: function (range, doc) { 2141 if (range.startOffset !== 0 || range.startContainer.nodeType !== 1) { 2142 return false; 2143 } 2144 2145 if (range.startContainer.nodeName.toLowerCase() === 'img') { 2146 return true; 2147 } 2148 2149 var img = range.startContainer.querySelector('img'); 2150 if (!img) { 2151 return false; 2152 } 2153 2154 var treeWalker = doc.createTreeWalker(range.startContainer, NodeFilter.SHOW_ALL, null, false); 2155 while (treeWalker.nextNode()) { 2156 var next = treeWalker.currentNode; 2157 // If we hit the image, then there isn't any text before the image so 2158 // the image is at the beginning of the range 2159 if (next === img) { 2160 break; 2161 } 2162 // If we haven't hit the iamge, but found text that contains content 2163 // then the range doesn't start with an image 2164 if (next.nodeValue) { 2165 return false; 2166 } 2167 } 2168 2169 return true; 2170 }, 2171 2172 getTrailingImageCount: function (root, selectionState, endContainer, endOffset) { 2173 // If the endOffset of a range is 0, the endContainer doesn't contain images 2174 // If the endContainer is a text node, there are no trailing images 2175 if (endOffset === 0 || endContainer.nodeType !== 1) { 2176 return 0; 2177 } 2178 2179 // If the endContainer isn't an image, and doesn't have an image descendants 2180 // there are no trailing images 2181 if (endContainer.nodeName.toLowerCase() !== 'img' && !endContainer.querySelector('img')) { 2182 return 0; 2183 } 2184 2185 var lastNode = endContainer.childNodes[endOffset - 1]; 2186 while (lastNode.hasChildNodes()) { 2187 lastNode = lastNode.lastChild; 2188 } 2189 2190 var node = root, 2191 nodeStack = [], 2192 charIndex = 0, 2193 foundStart = false, 2194 foundEnd = false, 2195 stop = false, 2196 nextCharIndex, 2197 trailingImages = 0; 2198 2199 while (!stop && node) { 2200 // Only iterate over elements and text nodes 2201 if (node.nodeType > 3) { 2202 node = nodeStack.pop(); 2203 continue; 2204 } 2205 2206 if (node.nodeType === 3 && !foundEnd) { 2207 trailingImages = 0; 2208 nextCharIndex = charIndex + node.length; 2209 if (!foundStart && selectionState.start >= charIndex && selectionState.start <= nextCharIndex) { 2210 foundStart = true; 2211 } 2212 if (foundStart && selectionState.end >= charIndex && selectionState.end <= nextCharIndex) { 2213 foundEnd = true; 2214 } 2215 charIndex = nextCharIndex; 2216 } else { 2217 if (node.nodeName.toLowerCase() === 'img') { 2218 trailingImages++; 2219 } 2220 2221 if (node === lastNode) { 2222 stop = true; 2223 } else if (node.nodeType === 1) { 2224 // this is an element 2225 // add all its children to the stack 2226 var i = node.childNodes.length - 1; 2227 while (i >= 0) { 2228 nodeStack.push(node.childNodes[i]); 2229 i -= 1; 2230 } 2231 } 2232 } 2233 2234 if (!stop) { 2235 node = nodeStack.pop(); 2236 } 2237 } 2238 2239 return trailingImages; 2240 }, 2241 2242 // determine if the current selection contains any 'content' 2243 // content being any non-white space text or an image 2244 selectionContainsContent: function (doc) { 2245 var sel = doc.getSelection(); 2246 2247 // collapsed selection or selection withour range doesn't contain content 2248 if (!sel || sel.isCollapsed || !sel.rangeCount) { 2249 return false; 2250 } 2251 2252 // if toString() contains any text, the selection contains some content 2253 if (sel.toString().trim() !== '') { 2254 return true; 2255 } 2256 2257 // if selection contains only image(s), it will return empty for toString() 2258 // so check for an image manually 2259 var selectionNode = this.getSelectedParentElement(sel.getRangeAt(0)); 2260 if (selectionNode) { 2261 if (selectionNode.nodeName.toLowerCase() === 'img' || 2262 (selectionNode.nodeType === 1 && selectionNode.querySelector('img'))) { 2263 return true; 2264 } 2265 } 2266 2267 return false; 2268 }, 2269 2270 selectionInContentEditableFalse: function (contentWindow) { 2271 // determine if the current selection is exclusively inside 2272 // a contenteditable="false", though treat the case of an 2273 // explicit contenteditable="true" inside a "false" as false. 2274 var sawtrue, 2275 sawfalse = this.findMatchingSelectionParent(function (el) { 2276 var ce = el && el.getAttribute('contenteditable'); 2277 if (ce === 'true') { 2278 sawtrue = true; 2279 } 2280 return el.nodeName !== '#text' && ce === 'false'; 2281 }, contentWindow); 2282 2283 return !sawtrue && sawfalse; 2284 }, 2285 2286 // http://stackoverflow.com/questions/4176923/html-of-selected-text 2287 // by Tim Down 2288 getSelectionHtml: function getSelectionHtml(doc) { 2289 var i, 2290 html = '', 2291 sel = doc.getSelection(), 2292 len, 2293 container; 2294 if (sel.rangeCount) { 2295 container = doc.createElement('div'); 2296 for (i = 0, len = sel.rangeCount; i < len; i += 1) { 2297 container.appendChild(sel.getRangeAt(i).cloneContents()); 2298 } 2299 html = container.innerHTML; 2300 } 2301 return html; 2302 }, 2303 2304 /** 2305 * Find the caret position within an element irrespective of any inline tags it may contain. 2306 * 2307 * @param {DOMElement} An element containing the cursor to find offsets relative to. 2308 * @param {Range} A Range representing cursor position. Will window.getSelection if none is passed. 2309 * @return {Object} 'left' and 'right' attributes contain offsets from begining and end of Element 2310 */ 2311 getCaretOffsets: function getCaretOffsets(element, range) { 2312 var preCaretRange, postCaretRange; 2313 2314 if (!range) { 2315 range = window.getSelection().getRangeAt(0); 2316 } 2317 2318 preCaretRange = range.cloneRange(); 2319 postCaretRange = range.cloneRange(); 2320 2321 preCaretRange.selectNodeContents(element); 2322 preCaretRange.setEnd(range.endContainer, range.endOffset); 2323 2324 postCaretRange.selectNodeContents(element); 2325 postCaretRange.setStart(range.endContainer, range.endOffset); 2326 2327 return { 2328 left: preCaretRange.toString().length, 2329 right: postCaretRange.toString().length 2330 }; 2331 }, 2332 2333 // http://stackoverflow.com/questions/15867542/range-object-get-selection-parent-node-chrome-vs-firefox 2334 rangeSelectsSingleNode: function (range) { 2335 var startNode = range.startContainer; 2336 return startNode === range.endContainer && 2337 startNode.hasChildNodes() && 2338 range.endOffset === range.startOffset + 1; 2339 }, 2340 2341 getSelectedParentElement: function (range) { 2342 if (!range) { 2343 return null; 2344 } 2345 2346 // Selection encompasses a single element 2347 if (this.rangeSelectsSingleNode(range) && range.startContainer.childNodes[range.startOffset].nodeType !== 3) { 2348 return range.startContainer.childNodes[range.startOffset]; 2349 } 2350 2351 // Selection range starts inside a text node, so get its parent 2352 if (range.startContainer.nodeType === 3) { 2353 return range.startContainer.parentNode; 2354 } 2355 2356 // Selection starts inside an element 2357 return range.startContainer; 2358 }, 2359 2360 getSelectedElements: function (doc) { 2361 var selection = doc.getSelection(), 2362 range, 2363 toRet, 2364 currNode; 2365 2366 if (!selection.rangeCount || selection.isCollapsed || !selection.getRangeAt(0).commonAncestorContainer) { 2367 return []; 2368 } 2369 2370 range = selection.getRangeAt(0); 2371 2372 if (range.commonAncestorContainer.nodeType === 3) { 2373 toRet = []; 2374 currNode = range.commonAncestorContainer; 2375 while (currNode.parentNode && currNode.parentNode.childNodes.length === 1) { 2376 toRet.push(currNode.parentNode); 2377 currNode = currNode.parentNode; 2378 } 2379 2380 return toRet; 2381 } 2382 2383 return [].filter.call(range.commonAncestorContainer.getElementsByTagName('*'), function (el) { 2384 return (typeof selection.containsNode === 'function') ? selection.containsNode(el, true) : true; 2385 }); 2386 }, 2387 2388 selectNode: function (node, doc) { 2389 var range = doc.createRange(); 2390 range.selectNodeContents(node); 2391 this.selectRange(doc, range); 2392 }, 2393 2394 select: function (doc, startNode, startOffset, endNode, endOffset) { 2395 var range = doc.createRange(); 2396 range.setStart(startNode, startOffset); 2397 if (endNode) { 2398 range.setEnd(endNode, endOffset); 2399 } else { 2400 range.collapse(true); 2401 } 2402 this.selectRange(doc, range); 2403 return range; 2404 }, 2405 2406 /** 2407 * Clear the current highlighted selection and set the caret to the start or the end of that prior selection, defaults to end. 2408 * 2409 * @param {DomDocument} doc Current document 2410 * @param {boolean} moveCursorToStart A boolean representing whether or not to set the caret to the beginning of the prior selection. 2411 */ 2412 clearSelection: function (doc, moveCursorToStart) { 2413 if (moveCursorToStart) { 2414 doc.getSelection().collapseToStart(); 2415 } else { 2416 doc.getSelection().collapseToEnd(); 2417 } 2418 }, 2419 2420 /** 2421 * Move cursor to the given node with the given offset. 2422 * 2423 * @param {DomDocument} doc Current document 2424 * @param {DomElement} node Element where to jump 2425 * @param {integer} offset Where in the element should we jump, 0 by default 2426 */ 2427 moveCursor: function (doc, node, offset) { 2428 this.select(doc, node, offset); 2429 }, 2430 2431 getSelectionRange: function (ownerDocument) { 2432 var selection = ownerDocument.getSelection(); 2433 if (selection.rangeCount === 0) { 2434 return null; 2435 } 2436 return selection.getRangeAt(0); 2437 }, 2438 2439 selectRange: function (ownerDocument, range) { 2440 var selection = ownerDocument.getSelection(); 2441 2442 selection.removeAllRanges(); 2443 selection.addRange(range); 2444 }, 2445 2446 // http://stackoverflow.com/questions/1197401/how-can-i-get-the-element-the-caret-is-in-with-javascript-when-using-contentedi 2447 // by You 2448 getSelectionStart: function (ownerDocument) { 2449 var node = ownerDocument.getSelection().anchorNode, 2450 startNode = (node && node.nodeType === 3 ? node.parentNode : node); 2451 2452 return startNode; 2453 } 2454 }; 2455 2456 MediumEditor.selection = Selection; 2457}()); 2458 2459(function () { 2460 'use strict'; 2461 2462 function isElementDescendantOfExtension(extensions, element) { 2463 return extensions.some(function (extension) { 2464 if (typeof extension.getInteractionElements !== 'function') { 2465 return false; 2466 } 2467 2468 var extensionElements = extension.getInteractionElements(); 2469 if (!extensionElements) { 2470 return false; 2471 } 2472 2473 if (!Array.isArray(extensionElements)) { 2474 extensionElements = [extensionElements]; 2475 } 2476 return extensionElements.some(function (el) { 2477 return MediumEditor.util.isDescendant(el, element, true); 2478 }); 2479 }); 2480 } 2481 2482 var Events = function (instance) { 2483 this.base = instance; 2484 this.options = this.base.options; 2485 this.events = []; 2486 this.disabledEvents = {}; 2487 this.customEvents = {}; 2488 this.listeners = {}; 2489 }; 2490 2491 Events.prototype = { 2492 InputEventOnContenteditableSupported: !MediumEditor.util.isIE && !MediumEditor.util.isEdge, 2493 2494 // Helpers for event handling 2495 2496 attachDOMEvent: function (targets, event, listener, useCapture) { 2497 var win = this.base.options.contentWindow, 2498 doc = this.base.options.ownerDocument; 2499 2500 targets = MediumEditor.util.isElement(targets) || [win, doc].indexOf(targets) > -1 ? [targets] : targets; 2501 2502 Array.prototype.forEach.call(targets, function (target) { 2503 target.addEventListener(event, listener, useCapture); 2504 this.events.push([target, event, listener, useCapture]); 2505 }.bind(this)); 2506 }, 2507 2508 detachDOMEvent: function (targets, event, listener, useCapture) { 2509 var index, e, 2510 win = this.base.options.contentWindow, 2511 doc = this.base.options.ownerDocument; 2512 2513 if (targets !== null) { 2514 targets = MediumEditor.util.isElement(targets) || [win, doc].indexOf(targets) > -1 ? [targets] : targets; 2515 2516 Array.prototype.forEach.call(targets, function (target) { 2517 index = this.indexOfListener(target, event, listener, useCapture); 2518 if (index !== -1) { 2519 e = this.events.splice(index, 1)[0]; 2520 e[0].removeEventListener(e[1], e[2], e[3]); 2521 } 2522 }.bind(this)); 2523 } 2524 }, 2525 2526 indexOfListener: function (target, event, listener, useCapture) { 2527 var i, n, item; 2528 for (i = 0, n = this.events.length; i < n; i = i + 1) { 2529 item = this.events[i]; 2530 if (item[0] === target && item[1] === event && item[2] === listener && item[3] === useCapture) { 2531 return i; 2532 } 2533 } 2534 return -1; 2535 }, 2536 2537 detachAllDOMEvents: function () { 2538 var e = this.events.pop(); 2539 while (e) { 2540 e[0].removeEventListener(e[1], e[2], e[3]); 2541 e = this.events.pop(); 2542 } 2543 }, 2544 2545 detachAllEventsFromElement: function (element) { 2546 var filtered = this.events.filter(function (e) { 2547 return e && e[0].getAttribute && e[0].getAttribute('medium-editor-index') === element.getAttribute('medium-editor-index'); 2548 }); 2549 2550 for (var i = 0, len = filtered.length; i < len; i++) { 2551 var e = filtered[i]; 2552 this.detachDOMEvent(e[0], e[1], e[2], e[3]); 2553 } 2554 }, 2555 2556 // Attach all existing handlers to a new element 2557 attachAllEventsToElement: function (element) { 2558 if (this.listeners['editableInput']) { 2559 this.contentCache[element.getAttribute('medium-editor-index')] = element.innerHTML; 2560 } 2561 2562 if (this.eventsCache) { 2563 this.eventsCache.forEach(function (e) { 2564 this.attachDOMEvent(element, e['name'], e['handler'].bind(this)); 2565 }, this); 2566 } 2567 }, 2568 2569 enableCustomEvent: function (event) { 2570 if (this.disabledEvents[event] !== undefined) { 2571 delete this.disabledEvents[event]; 2572 } 2573 }, 2574 2575 disableCustomEvent: function (event) { 2576 this.disabledEvents[event] = true; 2577 }, 2578 2579 // custom events 2580 attachCustomEvent: function (event, listener) { 2581 this.setupListener(event); 2582 if (!this.customEvents[event]) { 2583 this.customEvents[event] = []; 2584 } 2585 this.customEvents[event].push(listener); 2586 }, 2587 2588 detachCustomEvent: function (event, listener) { 2589 var index = this.indexOfCustomListener(event, listener); 2590 if (index !== -1) { 2591 this.customEvents[event].splice(index, 1); 2592 // TODO: If array is empty, should detach internal listeners via destroyListener() 2593 } 2594 }, 2595 2596 indexOfCustomListener: function (event, listener) { 2597 if (!this.customEvents[event] || !this.customEvents[event].length) { 2598 return -1; 2599 } 2600 2601 return this.customEvents[event].indexOf(listener); 2602 }, 2603 2604 detachAllCustomEvents: function () { 2605 this.customEvents = {}; 2606 // TODO: Should detach internal listeners here via destroyListener() 2607 }, 2608 2609 triggerCustomEvent: function (name, data, editable) { 2610 if (this.customEvents[name] && !this.disabledEvents[name]) { 2611 this.customEvents[name].forEach(function (listener) { 2612 listener(data, editable); 2613 }); 2614 } 2615 }, 2616 2617 // Cleaning up 2618 2619 destroy: function () { 2620 this.detachAllDOMEvents(); 2621 this.detachAllCustomEvents(); 2622 this.detachExecCommand(); 2623 2624 if (this.base.elements) { 2625 this.base.elements.forEach(function (element) { 2626 element.removeAttribute('data-medium-focused'); 2627 }); 2628 } 2629 }, 2630 2631 // Listening to calls to document.execCommand 2632 2633 // Attach a listener to be notified when document.execCommand is called 2634 attachToExecCommand: function () { 2635 if (this.execCommandListener) { 2636 return; 2637 } 2638 2639 // Store an instance of the listener so: 2640 // 1) We only attach to execCommand once 2641 // 2) We can remove the listener later 2642 this.execCommandListener = function (execInfo) { 2643 this.handleDocumentExecCommand(execInfo); 2644 }.bind(this); 2645 2646 // Ensure that execCommand has been wrapped correctly 2647 this.wrapExecCommand(); 2648 2649 // Add listener to list of execCommand listeners 2650 this.options.ownerDocument.execCommand.listeners.push(this.execCommandListener); 2651 }, 2652 2653 // Remove our listener for calls to document.execCommand 2654 detachExecCommand: function () { 2655 var doc = this.options.ownerDocument; 2656 if (!this.execCommandListener || !doc.execCommand.listeners) { 2657 return; 2658 } 2659 2660 // Find the index of this listener in the array of listeners so it can be removed 2661 var index = doc.execCommand.listeners.indexOf(this.execCommandListener); 2662 if (index !== -1) { 2663 doc.execCommand.listeners.splice(index, 1); 2664 } 2665 2666 // If the list of listeners is now empty, put execCommand back to its original state 2667 if (!doc.execCommand.listeners.length) { 2668 this.unwrapExecCommand(); 2669 } 2670 }, 2671 2672 // Wrap document.execCommand in a custom method so we can listen to calls to it 2673 wrapExecCommand: function () { 2674 var doc = this.options.ownerDocument; 2675 2676 // Ensure all instance of MediumEditor only wrap execCommand once 2677 if (doc.execCommand.listeners) { 2678 return; 2679 } 2680 2681 // Helper method to call all listeners to execCommand 2682 var callListeners = function (args, result) { 2683 if (doc.execCommand.listeners) { 2684 doc.execCommand.listeners.forEach(function (listener) { 2685 listener({ 2686 command: args[0], 2687 value: args[2], 2688 args: args, 2689 result: result 2690 }); 2691 }); 2692 } 2693 }, 2694 2695 // Create a wrapper method for execCommand which will: 2696 // 1) Call document.execCommand with the correct arguments 2697 // 2) Loop through any listeners and notify them that execCommand was called 2698 // passing extra info on the call 2699 // 3) Return the result 2700 wrapper = function () { 2701 var result = doc.execCommand.orig.apply(this, arguments); 2702 2703 if (!doc.execCommand.listeners) { 2704 return result; 2705 } 2706 2707 var args = Array.prototype.slice.call(arguments); 2708 callListeners(args, result); 2709 2710 return result; 2711 }; 2712 2713 // Store a reference to the original execCommand 2714 wrapper.orig = doc.execCommand; 2715 2716 // Attach an array for storing listeners 2717 wrapper.listeners = []; 2718 2719 // Helper for notifying listeners 2720 wrapper.callListeners = callListeners; 2721 2722 // Overwrite execCommand 2723 doc.execCommand = wrapper; 2724 }, 2725 2726 // Revert document.execCommand back to its original self 2727 unwrapExecCommand: function () { 2728 var doc = this.options.ownerDocument; 2729 if (!doc.execCommand.orig) { 2730 return; 2731 } 2732 2733 // Use the reference to the original execCommand to revert back 2734 doc.execCommand = doc.execCommand.orig; 2735 }, 2736 2737 // Listening to browser events to emit events medium-editor cares about 2738 setupListener: function (name) { 2739 if (this.listeners[name]) { 2740 return; 2741 } 2742 2743 switch (name) { 2744 case 'externalInteraction': 2745 // Detecting when user has interacted with elements outside of MediumEditor 2746 this.attachDOMEvent(this.options.ownerDocument.body, 'mousedown', this.handleBodyMousedown.bind(this), true); 2747 this.attachDOMEvent(this.options.ownerDocument.body, 'click', this.handleBodyClick.bind(this), true); 2748 this.attachDOMEvent(this.options.ownerDocument.body, 'focus', this.handleBodyFocus.bind(this), true); 2749 break; 2750 case 'blur': 2751 // Detecting when focus is lost 2752 this.setupListener('externalInteraction'); 2753 break; 2754 case 'focus': 2755 // Detecting when focus moves into some part of MediumEditor 2756 this.setupListener('externalInteraction'); 2757 break; 2758 case 'editableInput': 2759 // setup cache for knowing when the content has changed 2760 this.contentCache = {}; 2761 this.base.elements.forEach(function (element) { 2762 this.contentCache[element.getAttribute('medium-editor-index')] = element.innerHTML; 2763 }, this); 2764 2765 // Attach to the 'oninput' event, handled correctly by most browsers 2766 if (this.InputEventOnContenteditableSupported) { 2767 this.attachToEachElement('input', this.handleInput); 2768 } 2769 2770 // For browsers which don't support the input event on contenteditable (IE) 2771 // we'll attach to 'selectionchange' on the document and 'keypress' on the editables 2772 if (!this.InputEventOnContenteditableSupported) { 2773 this.setupListener('editableKeypress'); 2774 this.keypressUpdateInput = true; 2775 this.attachDOMEvent(document, 'selectionchange', this.handleDocumentSelectionChange.bind(this)); 2776 // Listen to calls to execCommand 2777 this.attachToExecCommand(); 2778 } 2779 break; 2780 case 'editableClick': 2781 // Detecting click in the contenteditables 2782 this.attachToEachElement('click', this.handleClick); 2783 break; 2784 case 'editableBlur': 2785 // Detecting blur in the contenteditables 2786 this.attachToEachElement('blur', this.handleBlur); 2787 break; 2788 case 'editableKeypress': 2789 // Detecting keypress in the contenteditables 2790 this.attachToEachElement('keypress', this.handleKeypress); 2791 break; 2792 case 'editableKeyup': 2793 // Detecting keyup in the contenteditables 2794 this.attachToEachElement('keyup', this.handleKeyup); 2795 break; 2796 case 'editableKeydown': 2797 // Detecting keydown on the contenteditables 2798 this.attachToEachElement('keydown', this.handleKeydown); 2799 break; 2800 case 'editableKeydownSpace': 2801 // Detecting keydown for SPACE on the contenteditables 2802 this.setupListener('editableKeydown'); 2803 break; 2804 case 'editableKeydownEnter': 2805 // Detecting keydown for ENTER on the contenteditables 2806 this.setupListener('editableKeydown'); 2807 break; 2808 case 'editableKeydownTab': 2809 // Detecting keydown for TAB on the contenteditable 2810 this.setupListener('editableKeydown'); 2811 break; 2812 case 'editableKeydownDelete': 2813 // Detecting keydown for DELETE/BACKSPACE on the contenteditables 2814 this.setupListener('editableKeydown'); 2815 break; 2816 case 'editableMouseover': 2817 // Detecting mouseover on the contenteditables 2818 this.attachToEachElement('mouseover', this.handleMouseover); 2819 break; 2820 case 'editableDrag': 2821 // Detecting dragover and dragleave on the contenteditables 2822 this.attachToEachElement('dragover', this.handleDragging); 2823 this.attachToEachElement('dragleave', this.handleDragging); 2824 break; 2825 case 'editableDrop': 2826 // Detecting drop on the contenteditables 2827 this.attachToEachElement('drop', this.handleDrop); 2828 break; 2829 // TODO: We need to have a custom 'paste' event separate from 'editablePaste' 2830 // Need to think about the way to introduce this without breaking folks 2831 case 'editablePaste': 2832 // Detecting paste on the contenteditables 2833 this.attachToEachElement('paste', this.handlePaste); 2834 break; 2835 } 2836 this.listeners[name] = true; 2837 }, 2838 2839 attachToEachElement: function (name, handler) { 2840 // build our internal cache to know which element got already what handler attached 2841 if (!this.eventsCache) { 2842 this.eventsCache = []; 2843 } 2844 2845 this.base.elements.forEach(function (element) { 2846 this.attachDOMEvent(element, name, handler.bind(this)); 2847 }, this); 2848 2849 this.eventsCache.push({ 'name': name, 'handler': handler }); 2850 }, 2851 2852 cleanupElement: function (element) { 2853 var index = element.getAttribute('medium-editor-index'); 2854 if (index) { 2855 this.detachAllEventsFromElement(element); 2856 if (this.contentCache) { 2857 delete this.contentCache[index]; 2858 } 2859 } 2860 }, 2861 2862 focusElement: function (element) { 2863 element.focus(); 2864 this.updateFocus(element, { target: element, type: 'focus' }); 2865 }, 2866 2867 updateFocus: function (target, eventObj) { 2868 var hadFocus = this.base.getFocusedElement(), 2869 toFocus; 2870 2871 // For clicks, we need to know if the mousedown that caused the click happened inside the existing focused element 2872 // or one of the extension elements. If so, we don't want to focus another element 2873 if (hadFocus && 2874 eventObj.type === 'click' && 2875 this.lastMousedownTarget && 2876 (MediumEditor.util.isDescendant(hadFocus, this.lastMousedownTarget, true) || 2877 isElementDescendantOfExtension(this.base.extensions, this.lastMousedownTarget))) { 2878 toFocus = hadFocus; 2879 } 2880 2881 if (!toFocus) { 2882 this.base.elements.some(function (element) { 2883 // If the target is part of an editor element, this is the element getting focus 2884 if (!toFocus && (MediumEditor.util.isDescendant(element, target, true))) { 2885 toFocus = element; 2886 } 2887 2888 // bail if we found an element that's getting focus 2889 return !!toFocus; 2890 }, this); 2891 } 2892 2893 // Check if the target is external (not part of the editor, toolbar, or any other extension) 2894 var externalEvent = !MediumEditor.util.isDescendant(hadFocus, target, true) && 2895 !isElementDescendantOfExtension(this.base.extensions, target); 2896 2897 if (toFocus !== hadFocus) { 2898 // If element has focus, and focus is going outside of editor 2899 // Don't blur focused element if clicking on editor, toolbar, or anchorpreview 2900 if (hadFocus && externalEvent) { 2901 // Trigger blur on the editable that has lost focus 2902 hadFocus.removeAttribute('data-medium-focused'); 2903 this.triggerCustomEvent('blur', eventObj, hadFocus); 2904 } 2905 2906 // If focus is going into an editor element 2907 if (toFocus) { 2908 // Trigger focus on the editable that now has focus 2909 toFocus.setAttribute('data-medium-focused', true); 2910 this.triggerCustomEvent('focus', eventObj, toFocus); 2911 } 2912 } 2913 2914 if (externalEvent) { 2915 this.triggerCustomEvent('externalInteraction', eventObj); 2916 } 2917 }, 2918 2919 updateInput: function (target, eventObj) { 2920 if (!this.contentCache) { 2921 return; 2922 } 2923 // An event triggered which signifies that the user may have changed someting 2924 // Look in our cache of input for the contenteditables to see if something changed 2925 var index = target.getAttribute('medium-editor-index'), 2926 html = target.innerHTML; 2927 2928 if (html !== this.contentCache[index]) { 2929 // The content has changed since the last time we checked, fire the event 2930 this.triggerCustomEvent('editableInput', eventObj, target); 2931 } 2932 this.contentCache[index] = html; 2933 }, 2934 2935 handleDocumentSelectionChange: function (event) { 2936 // When selectionchange fires, target and current target are set 2937 // to document, since this is where the event is handled 2938 // However, currentTarget will have an 'activeElement' property 2939 // which will point to whatever element has focus. 2940 if (event.currentTarget && event.currentTarget.activeElement) { 2941 var activeElement = event.currentTarget.activeElement, 2942 currentTarget; 2943 // We can look at the 'activeElement' to determine if the selectionchange has 2944 // happened within a contenteditable owned by this instance of MediumEditor 2945 this.base.elements.some(function (element) { 2946 if (MediumEditor.util.isDescendant(element, activeElement, true)) { 2947 currentTarget = element; 2948 return true; 2949 } 2950 return false; 2951 }, this); 2952 2953 // We know selectionchange fired within one of our contenteditables 2954 if (currentTarget) { 2955 this.updateInput(currentTarget, { target: activeElement, currentTarget: currentTarget }); 2956 } 2957 } 2958 }, 2959 2960 handleDocumentExecCommand: function () { 2961 // document.execCommand has been called 2962 // If one of our contenteditables currently has focus, we should 2963 // attempt to trigger the 'editableInput' event 2964 var target = this.base.getFocusedElement(); 2965 if (target) { 2966 this.updateInput(target, { target: target, currentTarget: target }); 2967 } 2968 }, 2969 2970 handleBodyClick: function (event) { 2971 this.updateFocus(event.target, event); 2972 }, 2973 2974 handleBodyFocus: function (event) { 2975 this.updateFocus(event.target, event); 2976 }, 2977 2978 handleBodyMousedown: function (event) { 2979 this.lastMousedownTarget = event.target; 2980 }, 2981 2982 handleInput: function (event) { 2983 this.updateInput(event.currentTarget, event); 2984 }, 2985 2986 handleClick: function (event) { 2987 this.triggerCustomEvent('editableClick', event, event.currentTarget); 2988 }, 2989 2990 handleBlur: function (event) { 2991 this.triggerCustomEvent('editableBlur', event, event.currentTarget); 2992 }, 2993 2994 handleKeypress: function (event) { 2995 this.triggerCustomEvent('editableKeypress', event, event.currentTarget); 2996 2997 // If we're doing manual detection of the editableInput event we need 2998 // to check for input changes during 'keypress' 2999 if (this.keypressUpdateInput) { 3000 var eventObj = { target: event.target, currentTarget: event.currentTarget }; 3001 3002 // In IE, we need to let the rest of the event stack complete before we detect 3003 // changes to input, so using setTimeout here 3004 setTimeout(function () { 3005 this.updateInput(eventObj.currentTarget, eventObj); 3006 }.bind(this), 0); 3007 } 3008 }, 3009 3010 handleKeyup: function (event) { 3011 this.triggerCustomEvent('editableKeyup', event, event.currentTarget); 3012 }, 3013 3014 handleMouseover: function (event) { 3015 this.triggerCustomEvent('editableMouseover', event, event.currentTarget); 3016 }, 3017 3018 handleDragging: function (event) { 3019 this.triggerCustomEvent('editableDrag', event, event.currentTarget); 3020 }, 3021 3022 handleDrop: function (event) { 3023 this.triggerCustomEvent('editableDrop', event, event.currentTarget); 3024 }, 3025 3026 handlePaste: function (event) { 3027 this.triggerCustomEvent('editablePaste', event, event.currentTarget); 3028 }, 3029 3030 handleKeydown: function (event) { 3031 3032 this.triggerCustomEvent('editableKeydown', event, event.currentTarget); 3033 3034 if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.SPACE)) { 3035 return this.triggerCustomEvent('editableKeydownSpace', event, event.currentTarget); 3036 } 3037 3038 if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) || (event.ctrlKey && MediumEditor.util.isKey(event, MediumEditor.util.keyCode.M))) { 3039 return this.triggerCustomEvent('editableKeydownEnter', event, event.currentTarget); 3040 } 3041 3042 if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.TAB)) { 3043 return this.triggerCustomEvent('editableKeydownTab', event, event.currentTarget); 3044 } 3045 3046 if (MediumEditor.util.isKey(event, [MediumEditor.util.keyCode.DELETE, MediumEditor.util.keyCode.BACKSPACE])) { 3047 return this.triggerCustomEvent('editableKeydownDelete', event, event.currentTarget); 3048 } 3049 } 3050 }; 3051 3052 MediumEditor.Events = Events; 3053}()); 3054 3055(function () { 3056 'use strict'; 3057 3058 var Button = MediumEditor.Extension.extend({ 3059 3060 /* Button Options */ 3061 3062 /* action: [string] 3063 * The action argument to pass to MediumEditor.execAction() 3064 * when the button is clicked 3065 */ 3066 action: undefined, 3067 3068 /* aria: [string] 3069 * The value to add as the aria-label attribute of the button 3070 * element displayed in the toolbar. 3071 * This is also used as the tooltip for the button 3072 */ 3073 aria: undefined, 3074 3075 /* tagNames: [Array] 3076 * NOTE: This is not used if useQueryState is set to true. 3077 * 3078 * Array of element tag names that would indicate that this 3079 * button has already been applied. If this action has already 3080 * been applied, the button will be displayed as 'active' in the toolbar 3081 * 3082 * Example: 3083 * For 'bold', if the text is ever within a <b> or <strong> 3084 * tag that indicates the text is already bold. So the array 3085 * of tagNames for bold would be: ['b', 'strong'] 3086 */ 3087 tagNames: undefined, 3088 3089 /* style: [Object] 3090 * NOTE: This is not used if useQueryState is set to true. 3091 * 3092 * A pair of css property & value(s) that indicate that this 3093 * button has already been applied. If this action has already 3094 * been applied, the button will be displayed as 'active' in the toolbar 3095 * Properties of the object: 3096 * prop [String]: name of the css property 3097 * value [String]: value(s) of the css property 3098 * multiple values can be separated by a '|' 3099 * 3100 * Example: 3101 * For 'bold', if the text is ever within an element with a 'font-weight' 3102 * style property set to '700' or 'bold', that indicates the text 3103 * is already bold. So the style object for bold would be: 3104 * { prop: 'font-weight', value: '700|bold' } 3105 */ 3106 style: undefined, 3107 3108 /* useQueryState: [boolean] 3109 * Enables/disables whether this button should use the built-in 3110 * document.queryCommandState() method to determine whether 3111 * the action has already been applied. If the action has already 3112 * been applied, the button will be displayed as 'active' in the toolbar 3113 * 3114 * Example: 3115 * For 'bold', if this is set to true, the code will call: 3116 * document.queryCommandState('bold') which will return true if the 3117 * browser thinks the text is already bold, and false otherwise 3118 */ 3119 useQueryState: undefined, 3120 3121 /* contentDefault: [string] 3122 * Default innerHTML to put inside the button 3123 */ 3124 contentDefault: undefined, 3125 3126 /* contentFA: [string] 3127 * The innerHTML to use for the content of the button 3128 * if the `buttonLabels` option for MediumEditor is set to 'fontawesome' 3129 */ 3130 contentFA: undefined, 3131 3132 /* classList: [Array] 3133 * An array of classNames (strings) to be added to the button 3134 */ 3135 classList: undefined, 3136 3137 /* attrs: [object] 3138 * A set of key-value pairs to add to the button as custom attributes 3139 */ 3140 attrs: undefined, 3141 3142 // The button constructor can optionally accept the name of a built-in button 3143 // (ie 'bold', 'italic', etc.) 3144 // When the name of a button is passed, it will initialize itself with the 3145 // configuration for that button 3146 constructor: function (options) { 3147 if (Button.isBuiltInButton(options)) { 3148 MediumEditor.Extension.call(this, this.defaults[options]); 3149 } else { 3150 MediumEditor.Extension.call(this, options); 3151 } 3152 }, 3153 3154 init: function () { 3155 MediumEditor.Extension.prototype.init.apply(this, arguments); 3156 3157 this.button = this.createButton(); 3158 this.on(this.button, 'click', this.handleClick.bind(this)); 3159 }, 3160 3161 /* getButton: [function ()] 3162 * 3163 * If implemented, this function will be called when 3164 * the toolbar is being created. The DOM Element returned 3165 * by this function will be appended to the toolbar along 3166 * with any other buttons. 3167 */ 3168 getButton: function () { 3169 return this.button; 3170 }, 3171 3172 getAction: function () { 3173 return (typeof this.action === 'function') ? this.action(this.base.options) : this.action; 3174 }, 3175 3176 getAria: function () { 3177 return (typeof this.aria === 'function') ? this.aria(this.base.options) : this.aria; 3178 }, 3179 3180 getTagNames: function () { 3181 return (typeof this.tagNames === 'function') ? this.tagNames(this.base.options) : this.tagNames; 3182 }, 3183 3184 createButton: function () { 3185 var button = this.document.createElement('button'), 3186 content = this.contentDefault, 3187 ariaLabel = this.getAria(), 3188 buttonLabels = this.getEditorOption('buttonLabels'); 3189 // Add class names 3190 button.classList.add('medium-editor-action'); 3191 button.classList.add('medium-editor-action-' + this.name); 3192 if (this.classList) { 3193 this.classList.forEach(function (className) { 3194 button.classList.add(className); 3195 }); 3196 } 3197 3198 // Add attributes 3199 button.setAttribute('data-action', this.getAction()); 3200 if (ariaLabel) { 3201 button.setAttribute('title', ariaLabel); 3202 button.setAttribute('aria-label', ariaLabel); 3203 } 3204 if (this.attrs) { 3205 Object.keys(this.attrs).forEach(function (attr) { 3206 button.setAttribute(attr, this.attrs[attr]); 3207 }, this); 3208 } 3209 3210 if (buttonLabels === 'fontawesome' && this.contentFA) { 3211 content = this.contentFA; 3212 } 3213 button.innerHTML = content; 3214 return button; 3215 }, 3216 3217 handleClick: function (event) { 3218 event.preventDefault(); 3219 event.stopPropagation(); 3220 3221 var action = this.getAction(); 3222 3223 if (action) { 3224 this.execAction(action); 3225 } 3226 }, 3227 3228 isActive: function () { 3229 return this.button.classList.contains(this.getEditorOption('activeButtonClass')); 3230 }, 3231 3232 setInactive: function () { 3233 this.button.classList.remove(this.getEditorOption('activeButtonClass')); 3234 delete this.knownState; 3235 }, 3236 3237 setActive: function () { 3238 this.button.classList.add(this.getEditorOption('activeButtonClass')); 3239 delete this.knownState; 3240 }, 3241 3242 queryCommandState: function () { 3243 var queryState = null; 3244 if (this.useQueryState) { 3245 queryState = this.base.queryCommandState(this.getAction()); 3246 } 3247 return queryState; 3248 }, 3249 3250 isAlreadyApplied: function (node) { 3251 var isMatch = false, 3252 tagNames = this.getTagNames(), 3253 styleVals, 3254 computedStyle; 3255 3256 if (this.knownState === false || this.knownState === true) { 3257 return this.knownState; 3258 } 3259 3260 if (tagNames && tagNames.length > 0) { 3261 isMatch = tagNames.indexOf(node.nodeName.toLowerCase()) !== -1; 3262 } 3263 3264 if (!isMatch && this.style) { 3265 styleVals = this.style.value.split('|'); 3266 computedStyle = this.window.getComputedStyle(node, null).getPropertyValue(this.style.prop); 3267 styleVals.forEach(function (val) { 3268 if (!this.knownState) { 3269 isMatch = (computedStyle.indexOf(val) !== -1); 3270 // text-decoration is not inherited by default 3271 // so if the computed style for text-decoration doesn't match 3272 // don't write to knownState so we can fallback to other checks 3273 if (isMatch || this.style.prop !== 'text-decoration') { 3274 this.knownState = isMatch; 3275 } 3276 } 3277 }, this); 3278 } 3279 3280 return isMatch; 3281 } 3282 }); 3283 3284 Button.isBuiltInButton = function (name) { 3285 return (typeof name === 'string') && MediumEditor.extensions.button.prototype.defaults.hasOwnProperty(name); 3286 }; 3287 3288 MediumEditor.extensions.button = Button; 3289}()); 3290 3291(function () { 3292 'use strict'; 3293 3294 /* MediumEditor.extensions.button.defaults: [Object] 3295 * Set of default config options for all of the built-in MediumEditor buttons 3296 */ 3297 MediumEditor.extensions.button.prototype.defaults = { 3298 'bold': { 3299 name: 'bold', 3300 action: 'bold', 3301 aria: 'bold', 3302 tagNames: ['b', 'strong'], 3303 style: { 3304 prop: 'font-weight', 3305 value: '700|bold' 3306 }, 3307 useQueryState: true, 3308 contentDefault: '<b>B</b>', 3309 contentFA: '<i class="fa fa-bold"></i>' 3310 }, 3311 'italic': { 3312 name: 'italic', 3313 action: 'italic', 3314 aria: 'italic', 3315 tagNames: ['i', 'em'], 3316 style: { 3317 prop: 'font-style', 3318 value: 'italic' 3319 }, 3320 useQueryState: true, 3321 contentDefault: '<b><i>I</i></b>', 3322 contentFA: '<i class="fa fa-italic"></i>' 3323 }, 3324 'underline': { 3325 name: 'underline', 3326 action: 'underline', 3327 aria: 'underline', 3328 tagNames: ['u'], 3329 style: { 3330 prop: 'text-decoration', 3331 value: 'underline' 3332 }, 3333 useQueryState: true, 3334 contentDefault: '<b><u>U</u></b>', 3335 contentFA: '<i class="fa fa-underline"></i>' 3336 }, 3337 'strikethrough': { 3338 name: 'strikethrough', 3339 action: 'strikethrough', 3340 aria: 'strike through', 3341 tagNames: ['strike'], 3342 style: { 3343 prop: 'text-decoration', 3344 value: 'line-through' 3345 }, 3346 useQueryState: true, 3347 contentDefault: '<s>A</s>', 3348 contentFA: '<i class="fa fa-strikethrough"></i>' 3349 }, 3350 'superscript': { 3351 name: 'superscript', 3352 action: 'superscript', 3353 aria: 'superscript', 3354 tagNames: ['sup'], 3355 /* firefox doesn't behave the way we want it to, so we CAN'T use queryCommandState for superscript 3356 https://github.com/guardian/scribe/blob/master/BROWSERINCONSISTENCIES.md#documentquerycommandstate */ 3357 // useQueryState: true 3358 contentDefault: '<b>x<sup>1</sup></b>', 3359 contentFA: '<i class="fa fa-superscript"></i>' 3360 }, 3361 'subscript': { 3362 name: 'subscript', 3363 action: 'subscript', 3364 aria: 'subscript', 3365 tagNames: ['sub'], 3366 /* firefox doesn't behave the way we want it to, so we CAN'T use queryCommandState for subscript 3367 https://github.com/guardian/scribe/blob/master/BROWSERINCONSISTENCIES.md#documentquerycommandstate */ 3368 // useQueryState: true 3369 contentDefault: '<b>x<sub>1</sub></b>', 3370 contentFA: '<i class="fa fa-subscript"></i>' 3371 }, 3372 'image': { 3373 name: 'image', 3374 action: 'image', 3375 aria: 'image', 3376 tagNames: ['img'], 3377 contentDefault: '<b>image</b>', 3378 contentFA: '<i class="fa fa-picture-o"></i>' 3379 }, 3380 'html': { 3381 name: 'html', 3382 action: 'html', 3383 aria: 'evaluate html', 3384 tagNames: ['iframe', 'object'], 3385 contentDefault: '<b>html</b>', 3386 contentFA: '<i class="fa fa-code"></i>' 3387 }, 3388 'orderedlist': { 3389 name: 'orderedlist', 3390 action: 'insertorderedlist', 3391 aria: 'ordered list', 3392 tagNames: ['ol'], 3393 useQueryState: true, 3394 contentDefault: '<b>1.</b>', 3395 contentFA: '<i class="fa fa-list-ol"></i>' 3396 }, 3397 'unorderedlist': { 3398 name: 'unorderedlist', 3399 action: 'insertunorderedlist', 3400 aria: 'unordered list', 3401 tagNames: ['ul'], 3402 useQueryState: true, 3403 contentDefault: '<b>•</b>', 3404 contentFA: '<i class="fa fa-list-ul"></i>' 3405 }, 3406 'indent': { 3407 name: 'indent', 3408 action: 'indent', 3409 aria: 'indent', 3410 tagNames: [], 3411 contentDefault: '<b>→</b>', 3412 contentFA: '<i class="fa fa-indent"></i>' 3413 }, 3414 'outdent': { 3415 name: 'outdent', 3416 action: 'outdent', 3417 aria: 'outdent', 3418 tagNames: [], 3419 contentDefault: '<b>←</b>', 3420 contentFA: '<i class="fa fa-outdent"></i>' 3421 }, 3422 'justifyCenter': { 3423 name: 'justifyCenter', 3424 action: 'justifyCenter', 3425 aria: 'center justify', 3426 tagNames: [], 3427 style: { 3428 prop: 'text-align', 3429 value: 'center' 3430 }, 3431 contentDefault: '<b>C</b>', 3432 contentFA: '<i class="fa fa-align-center"></i>' 3433 }, 3434 'justifyFull': { 3435 name: 'justifyFull', 3436 action: 'justifyFull', 3437 aria: 'full justify', 3438 tagNames: [], 3439 style: { 3440 prop: 'text-align', 3441 value: 'justify' 3442 }, 3443 contentDefault: '<b>J</b>', 3444 contentFA: '<i class="fa fa-align-justify"></i>' 3445 }, 3446 'justifyLeft': { 3447 name: 'justifyLeft', 3448 action: 'justifyLeft', 3449 aria: 'left justify', 3450 tagNames: [], 3451 style: { 3452 prop: 'text-align', 3453 value: 'left' 3454 }, 3455 contentDefault: '<b>L</b>', 3456 contentFA: '<i class="fa fa-align-left"></i>' 3457 }, 3458 'justifyRight': { 3459 name: 'justifyRight', 3460 action: 'justifyRight', 3461 aria: 'right justify', 3462 tagNames: [], 3463 style: { 3464 prop: 'text-align', 3465 value: 'right' 3466 }, 3467 contentDefault: '<b>R</b>', 3468 contentFA: '<i class="fa fa-align-right"></i>' 3469 }, 3470 // Known inline elements that are not removed, or not removed consistantly across browsers: 3471 // <span>, <label>, <br> 3472 'removeFormat': { 3473 name: 'removeFormat', 3474 aria: 'remove formatting', 3475 action: 'removeFormat', 3476 contentDefault: '<b>X</b>', 3477 contentFA: '<i class="fa fa-eraser"></i>' 3478 }, 3479 3480 /***** Buttons for appending block elements (append-<element> action) *****/ 3481 3482 'quote': { 3483 name: 'quote', 3484 action: 'append-blockquote', 3485 aria: 'blockquote', 3486 tagNames: ['blockquote'], 3487 contentDefault: '<b>“</b>', 3488 contentFA: '<i class="fa fa-quote-right"></i>' 3489 }, 3490 'pre': { 3491 name: 'pre', 3492 action: 'append-pre', 3493 aria: 'preformatted text', 3494 tagNames: ['pre'], 3495 contentDefault: '<b>0101</b>', 3496 contentFA: '<i class="fa fa-code fa-lg"></i>' 3497 }, 3498 'h1': { 3499 name: 'h1', 3500 action: 'append-h1', 3501 aria: 'header type one', 3502 tagNames: ['h1'], 3503 contentDefault: '<b>H1</b>', 3504 contentFA: '<i class="fa fa-header"><sup>1</sup>' 3505 }, 3506 'h2': { 3507 name: 'h2', 3508 action: 'append-h2', 3509 aria: 'header type two', 3510 tagNames: ['h2'], 3511 contentDefault: '<b>H2</b>', 3512 contentFA: '<i class="fa fa-header"><sup>2</sup>' 3513 }, 3514 'h3': { 3515 name: 'h3', 3516 action: 'append-h3', 3517 aria: 'header type three', 3518 tagNames: ['h3'], 3519 contentDefault: '<b>H3</b>', 3520 contentFA: '<i class="fa fa-header"><sup>3</sup>' 3521 }, 3522 'h4': { 3523 name: 'h4', 3524 action: 'append-h4', 3525 aria: 'header type four', 3526 tagNames: ['h4'], 3527 contentDefault: '<b>H4</b>', 3528 contentFA: '<i class="fa fa-header"><sup>4</sup>' 3529 }, 3530 'h5': { 3531 name: 'h5', 3532 action: 'append-h5', 3533 aria: 'header type five', 3534 tagNames: ['h5'], 3535 contentDefault: '<b>H5</b>', 3536 contentFA: '<i class="fa fa-header"><sup>5</sup>' 3537 }, 3538 'h6': { 3539 name: 'h6', 3540 action: 'append-h6', 3541 aria: 'header type six', 3542 tagNames: ['h6'], 3543 contentDefault: '<b>H6</b>', 3544 contentFA: '<i class="fa fa-header"><sup>6</sup>' 3545 } 3546 }; 3547 3548})(); 3549 3550(function () { 3551 'use strict'; 3552 3553 /* Base functionality for an extension which will display 3554 * a 'form' inside the toolbar 3555 */ 3556 var FormExtension = MediumEditor.extensions.button.extend({ 3557 3558 init: function () { 3559 MediumEditor.extensions.button.prototype.init.apply(this, arguments); 3560 }, 3561 3562 // default labels for the form buttons 3563 formSaveLabel: '✓', 3564 formCloseLabel: '×', 3565 3566 /* activeClass: [string] 3567 * set class which added to shown form 3568 */ 3569 activeClass: 'medium-editor-toolbar-form-active', 3570 3571 /* hasForm: [boolean] 3572 * 3573 * Setting this to true will cause getForm() to be called 3574 * when the toolbar is created, so the form can be appended 3575 * inside the toolbar container 3576 */ 3577 hasForm: true, 3578 3579 /* getForm: [function ()] 3580 * 3581 * When hasForm is true, this function must be implemented 3582 * and return a DOM Element which will be appended to 3583 * the toolbar container. The form should start hidden, and 3584 * the extension can choose when to hide/show it 3585 */ 3586 getForm: function () {}, 3587 3588 /* isDisplayed: [function ()] 3589 * 3590 * This function should return true/false reflecting 3591 * whether the form is currently displayed 3592 */ 3593 isDisplayed: function () { 3594 if (this.hasForm) { 3595 return this.getForm().classList.contains(this.activeClass); 3596 } 3597 return false; 3598 }, 3599 3600 /* hideForm: [function ()] 3601 * 3602 * This function should show the form element inside 3603 * the toolbar container 3604 */ 3605 showForm: function () { 3606 if (this.hasForm) { 3607 this.getForm().classList.add(this.activeClass); 3608 } 3609 }, 3610 3611 /* hideForm: [function ()] 3612 * 3613 * This function should hide the form element inside 3614 * the toolbar container 3615 */ 3616 hideForm: function () { 3617 if (this.hasForm) { 3618 this.getForm().classList.remove(this.activeClass); 3619 } 3620 }, 3621 3622 /************************ Helpers ************************ 3623 * The following are helpers that are either set by MediumEditor 3624 * during initialization, or are helper methods which either 3625 * route calls to the MediumEditor instance or provide common 3626 * functionality for all form extensions 3627 *********************************************************/ 3628 3629 /* showToolbarDefaultActions: [function ()] 3630 * 3631 * Helper method which will turn back the toolbar after canceling 3632 * the customized form 3633 */ 3634 showToolbarDefaultActions: function () { 3635 var toolbar = this.base.getExtensionByName('toolbar'); 3636 if (toolbar) { 3637 toolbar.showToolbarDefaultActions(); 3638 } 3639 }, 3640 3641 /* hideToolbarDefaultActions: [function ()] 3642 * 3643 * Helper function which will hide the default contents of the 3644 * toolbar, but leave the toolbar container in the same state 3645 * to allow a form to display its custom contents inside the toolbar 3646 */ 3647 hideToolbarDefaultActions: function () { 3648 var toolbar = this.base.getExtensionByName('toolbar'); 3649 if (toolbar) { 3650 toolbar.hideToolbarDefaultActions(); 3651 } 3652 }, 3653 3654 /* setToolbarPosition: [function ()] 3655 * 3656 * Helper function which will update the size and position 3657 * of the toolbar based on the toolbar content and the current 3658 * position of the user's selection 3659 */ 3660 setToolbarPosition: function () { 3661 var toolbar = this.base.getExtensionByName('toolbar'); 3662 if (toolbar) { 3663 toolbar.setToolbarPosition(); 3664 } 3665 } 3666 }); 3667 3668 MediumEditor.extensions.form = FormExtension; 3669})(); 3670(function () { 3671 'use strict'; 3672 3673 var AnchorForm = MediumEditor.extensions.form.extend({ 3674 /* Anchor Form Options */ 3675 3676 /* customClassOption: [string] (previously options.anchorButton + options.anchorButtonClass) 3677 * Custom class name the user can optionally have added to their created links (ie 'button'). 3678 * If passed as a non-empty string, a checkbox will be displayed allowing the user to choose 3679 * whether to have the class added to the created link or not. 3680 */ 3681 customClassOption: null, 3682 3683 /* customClassOptionText: [string] 3684 * text to be shown in the checkbox when the __customClassOption__ is being used. 3685 */ 3686 customClassOptionText: 'Button', 3687 3688 /* linkValidation: [boolean] (previously options.checkLinkFormat) 3689 * enables/disables check for common URL protocols on anchor links. 3690 */ 3691 linkValidation: false, 3692 3693 /* placeholderText: [string] (previously options.anchorInputPlaceholder) 3694 * text to be shown as placeholder of the anchor input. 3695 */ 3696 placeholderText: 'Paste or type a link', 3697 3698 /* targetCheckbox: [boolean] (previously options.anchorTarget) 3699 * enables/disables displaying a "Open in new window" checkbox, which when checked 3700 * changes the `target` attribute of the created link. 3701 */ 3702 targetCheckbox: false, 3703 3704 /* targetCheckboxText: [string] (previously options.anchorInputCheckboxLabel) 3705 * text to be shown in the checkbox enabled via the __targetCheckbox__ option. 3706 */ 3707 targetCheckboxText: 'Open in new window', 3708 3709 // Options for the Button base class 3710 name: 'anchor', 3711 action: 'createLink', 3712 aria: 'link', 3713 tagNames: ['a'], 3714 contentDefault: '<b>#</b>', 3715 contentFA: '<i class="fa fa-link"></i>', 3716 3717 init: function () { 3718 MediumEditor.extensions.form.prototype.init.apply(this, arguments); 3719 3720 this.subscribe('editableKeydown', this.handleKeydown.bind(this)); 3721 }, 3722 3723 // Called when the button the toolbar is clicked 3724 // Overrides ButtonExtension.handleClick 3725 handleClick: function (event) { 3726 event.preventDefault(); 3727 event.stopPropagation(); 3728 3729 var range = MediumEditor.selection.getSelectionRange(this.document); 3730 3731 if (range.startContainer.nodeName.toLowerCase() === 'a' || 3732 range.endContainer.nodeName.toLowerCase() === 'a' || 3733 MediumEditor.util.getClosestTag(MediumEditor.selection.getSelectedParentElement(range), 'a')) { 3734 return this.execAction('unlink'); 3735 } 3736 3737 if (!this.isDisplayed()) { 3738 this.showForm(); 3739 } 3740 3741 return false; 3742 }, 3743 3744 // Called when user hits the defined shortcut (CTRL / COMMAND + K) 3745 handleKeydown: function (event) { 3746 if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.K) && MediumEditor.util.isMetaCtrlKey(event) && !event.shiftKey) { 3747 this.handleClick(event); 3748 } 3749 }, 3750 3751 // Called by medium-editor to append form to the toolbar 3752 getForm: function () { 3753 if (!this.form) { 3754 this.form = this.createForm(); 3755 } 3756 return this.form; 3757 }, 3758 3759 getTemplate: function () { 3760 var template = [ 3761 '<input type="text" class="medium-editor-toolbar-input" placeholder="', this.placeholderText, '">' 3762 ]; 3763 3764 template.push( 3765 '<a href="#" class="medium-editor-toolbar-save">', 3766 this.getEditorOption('buttonLabels') === 'fontawesome' ? '<i class="fa fa-check"></i>' : this.formSaveLabel, 3767 '</a>' 3768 ); 3769 3770 template.push('<a href="#" class="medium-editor-toolbar-close">', 3771 this.getEditorOption('buttonLabels') === 'fontawesome' ? '<i class="fa fa-times"></i>' : this.formCloseLabel, 3772 '</a>'); 3773 3774 // both of these options are slightly moot with the ability to 3775 // override the various form buildup/serialize functions. 3776 3777 if (this.targetCheckbox) { 3778 // fixme: ideally, this targetCheckboxText would be a formLabel too, 3779 // figure out how to deprecate? also consider `fa-` icon default implcations. 3780 template.push( 3781 '<div class="medium-editor-toolbar-form-row">', 3782 '<input type="checkbox" class="medium-editor-toolbar-anchor-target" id="medium-editor-toolbar-anchor-target-field-' + this.getEditorId() + '">', 3783 '<label for="medium-editor-toolbar-anchor-target-field-' + this.getEditorId() + '">', 3784 this.targetCheckboxText, 3785 '</label>', 3786 '</div>' 3787 ); 3788 } 3789 3790 if (this.customClassOption) { 3791 // fixme: expose this `Button` text as a formLabel property, too 3792 // and provide similar access to a `fa-` icon default. 3793 template.push( 3794 '<div class="medium-editor-toolbar-form-row">', 3795 '<input type="checkbox" class="medium-editor-toolbar-anchor-button">', 3796 '<label>', 3797 this.customClassOptionText, 3798 '</label>', 3799 '</div>' 3800 ); 3801 } 3802 3803 return template.join(''); 3804 3805 }, 3806 3807 // Used by medium-editor when the default toolbar is to be displayed 3808 isDisplayed: function () { 3809 return MediumEditor.extensions.form.prototype.isDisplayed.apply(this); 3810 }, 3811 3812 hideForm: function () { 3813 MediumEditor.extensions.form.prototype.hideForm.apply(this); 3814 this.getInput().value = ''; 3815 }, 3816 3817 showForm: function (opts) { 3818 var input = this.getInput(), 3819 targetCheckbox = this.getAnchorTargetCheckbox(), 3820 buttonCheckbox = this.getAnchorButtonCheckbox(); 3821 3822 opts = opts || { value: '' }; 3823 // TODO: This is for backwards compatability 3824 // We don't need to support the 'string' argument in 6.0.0 3825 if (typeof opts === 'string') { 3826 opts = { 3827 value: opts 3828 }; 3829 } 3830 3831 this.base.saveSelection(); 3832 this.hideToolbarDefaultActions(); 3833 MediumEditor.extensions.form.prototype.showForm.apply(this); 3834 this.setToolbarPosition(); 3835 3836 input.value = opts.value; 3837 input.focus(); 3838 3839 // If we have a target checkbox, we want it to be checked/unchecked 3840 // based on whether the existing link has target=_blank 3841 if (targetCheckbox) { 3842 targetCheckbox.checked = opts.target === '_blank'; 3843 } 3844 3845 // If we have a custom class checkbox, we want it to be checked/unchecked 3846 // based on whether an existing link already has the class 3847 if (buttonCheckbox) { 3848 var classList = opts.buttonClass ? opts.buttonClass.split(' ') : []; 3849 buttonCheckbox.checked = (classList.indexOf(this.customClassOption) !== -1); 3850 } 3851 }, 3852 3853 // Called by core when tearing down medium-editor (destroy) 3854 destroy: function () { 3855 if (!this.form) { 3856 return false; 3857 } 3858 3859 if (this.form.parentNode) { 3860 this.form.parentNode.removeChild(this.form); 3861 } 3862 3863 delete this.form; 3864 }, 3865 3866 // core methods 3867 3868 getFormOpts: function () { 3869 // no notion of private functions? wanted `_getFormOpts` 3870 var targetCheckbox = this.getAnchorTargetCheckbox(), 3871 buttonCheckbox = this.getAnchorButtonCheckbox(), 3872 opts = { 3873 value: this.getInput().value.trim() 3874 }; 3875 3876 if (this.linkValidation) { 3877 opts.value = this.checkLinkFormat(opts.value); 3878 } 3879 3880 opts.target = '_self'; 3881 if (targetCheckbox && targetCheckbox.checked) { 3882 opts.target = '_blank'; 3883 } 3884 3885 if (buttonCheckbox && buttonCheckbox.checked) { 3886 opts.buttonClass = this.customClassOption; 3887 } 3888 3889 return opts; 3890 }, 3891 3892 doFormSave: function () { 3893 var opts = this.getFormOpts(); 3894 this.completeFormSave(opts); 3895 }, 3896 3897 completeFormSave: function (opts) { 3898 this.base.restoreSelection(); 3899 this.execAction(this.action, opts); 3900 this.base.checkSelection(); 3901 }, 3902 3903 ensureEncodedUri: function (str) { 3904 return str === decodeURI(str) ? encodeURI(str) : str; 3905 }, 3906 3907 ensureEncodedUriComponent: function (str) { 3908 return str === decodeURIComponent(str) ? encodeURIComponent(str) : str; 3909 }, 3910 3911 ensureEncodedParam: function (param) { 3912 var split = param.split('='), 3913 key = split[0], 3914 val = split[1]; 3915 3916 return key + (val === undefined ? '' : '=' + this.ensureEncodedUriComponent(val)); 3917 }, 3918 3919 ensureEncodedQuery: function (queryString) { 3920 return queryString.split('&').map(this.ensureEncodedParam.bind(this)).join('&'); 3921 }, 3922 3923 checkLinkFormat: function (value) { 3924 // Matches any alphabetical characters followed by :// 3925 // Matches protocol relative "//" 3926 // Matches common external protocols "mailto:" "tel:" "maps:" 3927 // Matches relative hash link, begins with "#" 3928 var urlSchemeRegex = /^([a-z]+:)?\/\/|^(mailto|tel|maps):|^\#/i, 3929 hasScheme = urlSchemeRegex.test(value), 3930 scheme = '', 3931 // telRegex is a regex for checking if the string is a telephone number 3932 telRegex = /^\+?\s?\(?(?:\d\s?\-?\)?){3,20}$/, 3933 urlParts = value.match(/^(.*?)(?:\?(.*?))?(?:#(.*))?$/), 3934 path = urlParts[1], 3935 query = urlParts[2], 3936 fragment = urlParts[3]; 3937 3938 if (telRegex.test(value)) { 3939 return 'tel:' + value; 3940 } 3941 3942 if (!hasScheme) { 3943 var host = path.split('/')[0]; 3944 // if the host part of the path looks like a hostname 3945 if (host.match(/.+(\.|:).+/) || host === 'localhost') { 3946 scheme = 'http://'; 3947 } 3948 } 3949 3950 return scheme + 3951 // Ensure path is encoded 3952 this.ensureEncodedUri(path) + 3953 // Ensure query is encoded 3954 (query === undefined ? '' : '?' + this.ensureEncodedQuery(query)) + 3955 // Include fragment unencoded as encodeUriComponent is too 3956 // heavy handed for the many characters allowed in a fragment 3957 (fragment === undefined ? '' : '#' + fragment); 3958 }, 3959 3960 doFormCancel: function () { 3961 this.base.restoreSelection(); 3962 this.base.checkSelection(); 3963 }, 3964 3965 // form creation and event handling 3966 attachFormEvents: function (form) { 3967 var close = form.querySelector('.medium-editor-toolbar-close'), 3968 save = form.querySelector('.medium-editor-toolbar-save'), 3969 input = form.querySelector('.medium-editor-toolbar-input'); 3970 3971 // Handle clicks on the form itself 3972 this.on(form, 'click', this.handleFormClick.bind(this)); 3973 3974 // Handle typing in the textbox 3975 this.on(input, 'keyup', this.handleTextboxKeyup.bind(this)); 3976 3977 // Handle close button clicks 3978 this.on(close, 'click', this.handleCloseClick.bind(this)); 3979 3980 // Handle save button clicks (capture) 3981 this.on(save, 'click', this.handleSaveClick.bind(this), true); 3982 3983 }, 3984 3985 createForm: function () { 3986 var doc = this.document, 3987 form = doc.createElement('div'); 3988 3989 // Anchor Form (div) 3990 form.className = 'medium-editor-toolbar-form'; 3991 form.id = 'medium-editor-toolbar-form-anchor-' + this.getEditorId(); 3992 form.innerHTML = this.getTemplate(); 3993 this.attachFormEvents(form); 3994 3995 return form; 3996 }, 3997 3998 getInput: function () { 3999 return this.getForm().querySelector('input.medium-editor-toolbar-input'); 4000 }, 4001 4002 getAnchorTargetCheckbox: function () { 4003 return this.getForm().querySelector('.medium-editor-toolbar-anchor-target'); 4004 }, 4005 4006 getAnchorButtonCheckbox: function () { 4007 return this.getForm().querySelector('.medium-editor-toolbar-anchor-button'); 4008 }, 4009 4010 handleTextboxKeyup: function (event) { 4011 // For ENTER -> create the anchor 4012 if (event.keyCode === MediumEditor.util.keyCode.ENTER) { 4013 event.preventDefault(); 4014 this.doFormSave(); 4015 return; 4016 } 4017 4018 // For ESCAPE -> close the form 4019 if (event.keyCode === MediumEditor.util.keyCode.ESCAPE) { 4020 event.preventDefault(); 4021 this.doFormCancel(); 4022 } 4023 }, 4024 4025 handleFormClick: function (event) { 4026 // make sure not to hide form when clicking inside the form 4027 event.stopPropagation(); 4028 }, 4029 4030 handleSaveClick: function (event) { 4031 // Clicking Save -> create the anchor 4032 event.preventDefault(); 4033 this.doFormSave(); 4034 }, 4035 4036 handleCloseClick: function (event) { 4037 // Click Close -> close the form 4038 event.preventDefault(); 4039 this.doFormCancel(); 4040 } 4041 }); 4042 4043 MediumEditor.extensions.anchor = AnchorForm; 4044}()); 4045 4046(function () { 4047 'use strict'; 4048 4049 var AnchorPreview = MediumEditor.Extension.extend({ 4050 name: 'anchor-preview', 4051 4052 // Anchor Preview Options 4053 4054 /* hideDelay: [number] (previously options.anchorPreviewHideDelay) 4055 * time in milliseconds to show the anchor tag preview after the mouse has left the anchor tag. 4056 */ 4057 hideDelay: 500, 4058 4059 /* previewValueSelector: [string] 4060 * the default selector to locate where to put the activeAnchor value in the preview 4061 */ 4062 previewValueSelector: 'a', 4063 4064 /* showWhenToolbarIsVisible: [boolean] 4065 * determines whether the anchor tag preview shows up when the toolbar is visible 4066 */ 4067 showWhenToolbarIsVisible: false, 4068 4069 /* showOnEmptyLinks: [boolean] 4070 * determines whether the anchor tag preview shows up on links with href="" or href="#something" 4071 */ 4072 showOnEmptyLinks: true, 4073 4074 init: function () { 4075 this.anchorPreview = this.createPreview(); 4076 4077 this.getEditorOption('elementsContainer').appendChild(this.anchorPreview); 4078 4079 this.attachToEditables(); 4080 }, 4081 4082 getInteractionElements: function () { 4083 return this.getPreviewElement(); 4084 }, 4085 4086 // TODO: Remove this function in 6.0.0 4087 getPreviewElement: function () { 4088 return this.anchorPreview; 4089 }, 4090 4091 createPreview: function () { 4092 var el = this.document.createElement('div'); 4093 4094 el.id = 'medium-editor-anchor-preview-' + this.getEditorId(); 4095 el.className = 'medium-editor-anchor-preview'; 4096 el.innerHTML = this.getTemplate(); 4097 4098 this.on(el, 'click', this.handleClick.bind(this)); 4099 4100 return el; 4101 }, 4102 4103 getTemplate: function () { 4104 return '<div class="medium-editor-toolbar-anchor-preview" id="medium-editor-toolbar-anchor-preview">' + 4105 ' <a class="medium-editor-toolbar-anchor-preview-inner"></a>' + 4106 '</div>'; 4107 }, 4108 4109 destroy: function () { 4110 if (this.anchorPreview) { 4111 if (this.anchorPreview.parentNode) { 4112 this.anchorPreview.parentNode.removeChild(this.anchorPreview); 4113 } 4114 delete this.anchorPreview; 4115 } 4116 }, 4117 4118 hidePreview: function () { 4119 if (this.anchorPreview) { 4120 this.anchorPreview.classList.remove('medium-editor-anchor-preview-active'); 4121 } 4122 this.activeAnchor = null; 4123 }, 4124 4125 showPreview: function (anchorEl) { 4126 if (this.anchorPreview.classList.contains('medium-editor-anchor-preview-active') || 4127 anchorEl.getAttribute('data-disable-preview')) { 4128 return true; 4129 } 4130 4131 if (this.previewValueSelector) { 4132 this.anchorPreview.querySelector(this.previewValueSelector).textContent = anchorEl.attributes.href.value; 4133 this.anchorPreview.querySelector(this.previewValueSelector).href = anchorEl.attributes.href.value; 4134 } 4135 4136 this.anchorPreview.classList.add('medium-toolbar-arrow-over'); 4137 this.anchorPreview.classList.remove('medium-toolbar-arrow-under'); 4138 4139 if (!this.anchorPreview.classList.contains('medium-editor-anchor-preview-active')) { 4140 this.anchorPreview.classList.add('medium-editor-anchor-preview-active'); 4141 } 4142 4143 this.activeAnchor = anchorEl; 4144 4145 this.positionPreview(); 4146 this.attachPreviewHandlers(); 4147 4148 return this; 4149 }, 4150 4151 positionPreview: function (activeAnchor) { 4152 activeAnchor = activeAnchor || this.activeAnchor; 4153 var containerWidth = this.window.innerWidth, 4154 buttonHeight = this.anchorPreview.offsetHeight, 4155 boundary = activeAnchor.getBoundingClientRect(), 4156 diffLeft = this.diffLeft, 4157 diffTop = this.diffTop, 4158 elementsContainer = this.getEditorOption('elementsContainer'), 4159 elementsContainerAbsolute = ['absolute', 'fixed'].indexOf(window.getComputedStyle(elementsContainer).getPropertyValue('position')) > -1, 4160 relativeBoundary = {}, 4161 halfOffsetWidth, defaultLeft, middleBoundary, elementsContainerBoundary, top; 4162 4163 halfOffsetWidth = this.anchorPreview.offsetWidth / 2; 4164 var toolbarExtension = this.base.getExtensionByName('toolbar'); 4165 if (toolbarExtension) { 4166 diffLeft = toolbarExtension.diffLeft; 4167 diffTop = toolbarExtension.diffTop; 4168 } 4169 defaultLeft = diffLeft - halfOffsetWidth; 4170 4171 // If container element is absolute / fixed, recalculate boundaries to be relative to the container 4172 if (elementsContainerAbsolute) { 4173 elementsContainerBoundary = elementsContainer.getBoundingClientRect(); 4174 ['top', 'left'].forEach(function (key) { 4175 relativeBoundary[key] = boundary[key] - elementsContainerBoundary[key]; 4176 }); 4177 4178 relativeBoundary.width = boundary.width; 4179 relativeBoundary.height = boundary.height; 4180 boundary = relativeBoundary; 4181 4182 containerWidth = elementsContainerBoundary.width; 4183 4184 // Adjust top position according to container scroll position 4185 top = elementsContainer.scrollTop; 4186 } else { 4187 // Adjust top position according to window scroll position 4188 top = this.window.pageYOffset; 4189 } 4190 4191 middleBoundary = boundary.left + boundary.width / 2; 4192 top += buttonHeight + boundary.top + boundary.height - diffTop - this.anchorPreview.offsetHeight; 4193 4194 this.anchorPreview.style.top = Math.round(top) + 'px'; 4195 this.anchorPreview.style.right = 'initial'; 4196 if (middleBoundary < halfOffsetWidth) { 4197 this.anchorPreview.style.left = defaultLeft + halfOffsetWidth + 'px'; 4198 this.anchorPreview.style.right = 'initial'; 4199 } else if ((containerWidth - middleBoundary) < halfOffsetWidth) { 4200 this.anchorPreview.style.left = 'auto'; 4201 this.anchorPreview.style.right = 0; 4202 } else { 4203 this.anchorPreview.style.left = defaultLeft + middleBoundary + 'px'; 4204 this.anchorPreview.style.right = 'initial'; 4205 } 4206 }, 4207 4208 attachToEditables: function () { 4209 this.subscribe('editableMouseover', this.handleEditableMouseover.bind(this)); 4210 this.subscribe('positionedToolbar', this.handlePositionedToolbar.bind(this)); 4211 }, 4212 4213 handlePositionedToolbar: function () { 4214 // If the toolbar is visible and positioned, we don't need to hide the preview 4215 // when showWhenToolbarIsVisible is true 4216 if (!this.showWhenToolbarIsVisible) { 4217 this.hidePreview(); 4218 } 4219 }, 4220 4221 handleClick: function (event) { 4222 var anchorExtension = this.base.getExtensionByName('anchor'), 4223 activeAnchor = this.activeAnchor; 4224 4225 if (anchorExtension && activeAnchor) { 4226 event.preventDefault(); 4227 4228 this.base.selectElement(this.activeAnchor); 4229 4230 // Using setTimeout + delay because: 4231 // We may actually be displaying the anchor form, which should be controlled by delay 4232 this.base.delay(function () { 4233 if (activeAnchor) { 4234 var opts = { 4235 value: activeAnchor.attributes.href.value, 4236 target: activeAnchor.getAttribute('target'), 4237 buttonClass: activeAnchor.getAttribute('class') 4238 }; 4239 anchorExtension.showForm(opts); 4240 activeAnchor = null; 4241 } 4242 }.bind(this)); 4243 } 4244 4245 this.hidePreview(); 4246 }, 4247 4248 handleAnchorMouseout: function () { 4249 this.anchorToPreview = null; 4250 this.off(this.activeAnchor, 'mouseout', this.instanceHandleAnchorMouseout); 4251 this.instanceHandleAnchorMouseout = null; 4252 }, 4253 4254 handleEditableMouseover: function (event) { 4255 var target = MediumEditor.util.getClosestTag(event.target, 'a'); 4256 4257 if (false === target) { 4258 return; 4259 } 4260 4261 // Detect empty href attributes 4262 // The browser will make href="" or href="#top" 4263 // into absolute urls when accessed as event.target.href, so check the html 4264 if (!this.showOnEmptyLinks && 4265 (!/href=["']\S+["']/.test(target.outerHTML) || /href=["']#\S+["']/.test(target.outerHTML))) { 4266 return true; 4267 } 4268 4269 // only show when toolbar is not present 4270 var toolbar = this.base.getExtensionByName('toolbar'); 4271 if (!this.showWhenToolbarIsVisible && toolbar && toolbar.isDisplayed && toolbar.isDisplayed()) { 4272 return true; 4273 } 4274 4275 // detach handler for other anchor in case we hovered multiple anchors quickly 4276 if (this.activeAnchor && this.activeAnchor !== target) { 4277 this.detachPreviewHandlers(); 4278 } 4279 4280 this.anchorToPreview = target; 4281 4282 this.instanceHandleAnchorMouseout = this.handleAnchorMouseout.bind(this); 4283 this.on(this.anchorToPreview, 'mouseout', this.instanceHandleAnchorMouseout); 4284 // Using setTimeout + delay because: 4285 // - We're going to show the anchor preview according to the configured delay 4286 // if the mouse has not left the anchor tag in that time 4287 this.base.delay(function () { 4288 if (this.anchorToPreview) { 4289 this.showPreview(this.anchorToPreview); 4290 } 4291 }.bind(this)); 4292 }, 4293 4294 handlePreviewMouseover: function () { 4295 this.lastOver = (new Date()).getTime(); 4296 this.hovering = true; 4297 }, 4298 4299 handlePreviewMouseout: function (event) { 4300 if (!event.relatedTarget || !/anchor-preview/.test(event.relatedTarget.className)) { 4301 this.hovering = false; 4302 } 4303 }, 4304 4305 updatePreview: function () { 4306 if (this.hovering) { 4307 return true; 4308 } 4309 var durr = (new Date()).getTime() - this.lastOver; 4310 if (durr > this.hideDelay) { 4311 // hide the preview 1/2 second after mouse leaves the link 4312 this.detachPreviewHandlers(); 4313 } 4314 }, 4315 4316 detachPreviewHandlers: function () { 4317 // cleanup 4318 clearInterval(this.intervalTimer); 4319 if (this.instanceHandlePreviewMouseover) { 4320 this.off(this.anchorPreview, 'mouseover', this.instanceHandlePreviewMouseover); 4321 this.off(this.anchorPreview, 'mouseout', this.instanceHandlePreviewMouseout); 4322 if (this.activeAnchor) { 4323 this.off(this.activeAnchor, 'mouseover', this.instanceHandlePreviewMouseover); 4324 this.off(this.activeAnchor, 'mouseout', this.instanceHandlePreviewMouseout); 4325 } 4326 } 4327 4328 this.hidePreview(); 4329 4330 this.hovering = this.instanceHandlePreviewMouseover = this.instanceHandlePreviewMouseout = null; 4331 }, 4332 4333 // TODO: break up method and extract out handlers 4334 attachPreviewHandlers: function () { 4335 this.lastOver = (new Date()).getTime(); 4336 this.hovering = true; 4337 4338 this.instanceHandlePreviewMouseover = this.handlePreviewMouseover.bind(this); 4339 this.instanceHandlePreviewMouseout = this.handlePreviewMouseout.bind(this); 4340 4341 this.intervalTimer = setInterval(this.updatePreview.bind(this), 200); 4342 4343 this.on(this.anchorPreview, 'mouseover', this.instanceHandlePreviewMouseover); 4344 this.on(this.anchorPreview, 'mouseout', this.instanceHandlePreviewMouseout); 4345 this.on(this.activeAnchor, 'mouseover', this.instanceHandlePreviewMouseover); 4346 this.on(this.activeAnchor, 'mouseout', this.instanceHandlePreviewMouseout); 4347 } 4348 }); 4349 4350 MediumEditor.extensions.anchorPreview = AnchorPreview; 4351}()); 4352 4353(function () { 4354 'use strict'; 4355 4356 var WHITESPACE_CHARS, 4357 KNOWN_TLDS_FRAGMENT, 4358 LINK_REGEXP_TEXT, 4359 KNOWN_TLDS_REGEXP, 4360 LINK_REGEXP; 4361 4362 WHITESPACE_CHARS = [' ', '\t', '\n', '\r', '\u00A0', '\u2000', '\u2001', '\u2002', '\u2003', 4363 '\u2028', '\u2029']; 4364 KNOWN_TLDS_FRAGMENT = 'com|net|org|edu|gov|mil|aero|asia|biz|cat|coop|info|int|jobs|mobi|museum|name|post|pro|tel|travel|' + 4365 '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|' + 4366 '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|' + 4367 '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|' + 4368 '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|' + 4369 '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|' + 4370 '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|' + 4371 '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'; 4372 4373 LINK_REGEXP_TEXT = 4374 '(' + 4375 // Version of Gruber URL Regexp optimized for JS: http://stackoverflow.com/a/17733640 4376 '((?:(https?://|ftps?://|nntp://)|www\\d{0,3}[.]|[a-z0-9.\\-]+[.](' + KNOWN_TLDS_FRAGMENT + ')\\\/)\\S+(?:[^\\s`!\\[\\]{};:\'\".,?\u00AB\u00BB\u201C\u201D\u2018\u2019]))' + 4377 // Addition to above Regexp to support bare domains/one level subdomains with common non-i18n TLDs and without www prefix: 4378 ')|(([a-z0-9\\-]+\\.)?[a-z0-9\\-]+\\.(' + KNOWN_TLDS_FRAGMENT + '))'; 4379 4380 KNOWN_TLDS_REGEXP = new RegExp('^(' + KNOWN_TLDS_FRAGMENT + ')$', 'i'); 4381 4382 LINK_REGEXP = new RegExp(LINK_REGEXP_TEXT, 'gi'); 4383 4384 function nodeIsNotInsideAnchorTag(node) { 4385 return !MediumEditor.util.getClosestTag(node, 'a'); 4386 } 4387 4388 var AutoLink = MediumEditor.Extension.extend({ 4389 init: function () { 4390 MediumEditor.Extension.prototype.init.apply(this, arguments); 4391 4392 this.disableEventHandling = false; 4393 this.subscribe('editableKeypress', this.onKeypress.bind(this)); 4394 this.subscribe('editableBlur', this.onBlur.bind(this)); 4395 // MS IE has it's own auto-URL detect feature but ours is better in some ways. Be consistent. 4396 this.document.execCommand('AutoUrlDetect', false, false); 4397 }, 4398 4399 isLastInstance: function () { 4400 var activeInstances = 0; 4401 for (var i = 0; i < this.window._mediumEditors.length; i++) { 4402 var editor = this.window._mediumEditors[i]; 4403 if (editor !== null && editor.getExtensionByName('autoLink') !== undefined) { 4404 activeInstances++; 4405 } 4406 } 4407 return activeInstances === 1; 4408 }, 4409 4410 destroy: function () { 4411 // Turn AutoUrlDetect back on 4412 if (this.document.queryCommandSupported('AutoUrlDetect') && this.isLastInstance()) { 4413 this.document.execCommand('AutoUrlDetect', false, true); 4414 } 4415 }, 4416 4417 onBlur: function (blurEvent, editable) { 4418 this.performLinking(editable); 4419 }, 4420 4421 onKeypress: function (keyPressEvent) { 4422 if (this.disableEventHandling) { 4423 return; 4424 } 4425 4426 if (MediumEditor.util.isKey(keyPressEvent, [MediumEditor.util.keyCode.SPACE, MediumEditor.util.keyCode.ENTER])) { 4427 clearTimeout(this.performLinkingTimeout); 4428 // Saving/restoring the selection in the middle of a keypress doesn't work well... 4429 this.performLinkingTimeout = setTimeout(function () { 4430 try { 4431 var sel = this.base.exportSelection(); 4432 if (this.performLinking(keyPressEvent.target)) { 4433 // pass true for favorLaterSelectionAnchor - this is needed for links at the end of a 4434 // paragraph in MS IE, or MS IE causes the link to be deleted right after adding it. 4435 this.base.importSelection(sel, true); 4436 } 4437 } catch (e) { 4438 if (window.console) { 4439 window.console.error('Failed to perform linking', e); 4440 } 4441 this.disableEventHandling = true; 4442 } 4443 }.bind(this), 0); 4444 } 4445 }, 4446 4447 performLinking: function (contenteditable) { 4448 /* 4449 Perform linking on blockElement basis, blockElements are HTML elements with text content and without 4450 child element. 4451 4452 Example: 4453 - HTML content 4454 <blockquote> 4455 <p>link.</p> 4456 <p>my</p> 4457 </blockquote> 4458 4459 - blockElements 4460 [<p>link.</p>, <p>my</p>] 4461 4462 otherwise the detection can wrongly find the end of one paragraph and the beginning of another paragraph 4463 to constitute a link, such as a paragraph ending "link." and the next paragraph beginning with "my" is 4464 interpreted into "link.my" and the code tries to create a link across blockElements - which doesn't work 4465 and is terrible. 4466 (Medium deletes the spaces/returns between P tags so the textContent ends up without paragraph spacing) 4467 */ 4468 var blockElements = MediumEditor.util.splitByBlockElements(contenteditable), 4469 documentModified = false; 4470 if (blockElements.length === 0) { 4471 blockElements = [contenteditable]; 4472 } 4473 for (var i = 0; i < blockElements.length; i++) { 4474 documentModified = this.removeObsoleteAutoLinkSpans(blockElements[i]) || documentModified; 4475 documentModified = this.performLinkingWithinElement(blockElements[i]) || documentModified; 4476 } 4477 this.base.events.updateInput(contenteditable, { target: contenteditable, currentTarget: contenteditable }); 4478 return documentModified; 4479 }, 4480 4481 removeObsoleteAutoLinkSpans: function (element) { 4482 if (!element || element.nodeType === 3) { 4483 return false; 4484 } 4485 4486 var spans = element.querySelectorAll('span[data-auto-link="true"]'), 4487 documentModified = false; 4488 4489 for (var i = 0; i < spans.length; i++) { 4490 var textContent = spans[i].textContent; 4491 if (textContent.indexOf('://') === -1) { 4492 textContent = MediumEditor.util.ensureUrlHasProtocol(textContent); 4493 } 4494 if (spans[i].getAttribute('data-href') !== textContent && nodeIsNotInsideAnchorTag(spans[i])) { 4495 documentModified = true; 4496 var trimmedTextContent = textContent.replace(/\s+$/, ''); 4497 if (spans[i].getAttribute('data-href') === trimmedTextContent) { 4498 var charactersTrimmed = textContent.length - trimmedTextContent.length, 4499 subtree = MediumEditor.util.splitOffDOMTree(spans[i], this.splitTextBeforeEnd(spans[i], charactersTrimmed)); 4500 spans[i].parentNode.insertBefore(subtree, spans[i].nextSibling); 4501 } else { 4502 // Some editing has happened to the span, so just remove it entirely. The user can put it back 4503 // around just the href content if they need to prevent it from linking 4504 MediumEditor.util.unwrap(spans[i], this.document); 4505 } 4506 } 4507 } 4508 return documentModified; 4509 }, 4510 4511 splitTextBeforeEnd: function (element, characterCount) { 4512 var treeWalker = this.document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false), 4513 lastChildNotExhausted = true; 4514 4515 // Start the tree walker at the last descendant of the span 4516 while (lastChildNotExhausted) { 4517 lastChildNotExhausted = treeWalker.lastChild() !== null; 4518 } 4519 4520 var currentNode, 4521 currentNodeValue, 4522 previousNode; 4523 while (characterCount > 0 && previousNode !== null) { 4524 currentNode = treeWalker.currentNode; 4525 currentNodeValue = currentNode.nodeValue; 4526 if (currentNodeValue.length > characterCount) { 4527 previousNode = currentNode.splitText(currentNodeValue.length - characterCount); 4528 characterCount = 0; 4529 } else { 4530 previousNode = treeWalker.previousNode(); 4531 characterCount -= currentNodeValue.length; 4532 } 4533 } 4534 return previousNode; 4535 }, 4536 4537 performLinkingWithinElement: function (element) { 4538 var matches = this.findLinkableText(element), 4539 linkCreated = false; 4540 4541 for (var matchIndex = 0; matchIndex < matches.length; matchIndex++) { 4542 var matchingTextNodes = MediumEditor.util.findOrCreateMatchingTextNodes(this.document, element, 4543 matches[matchIndex]); 4544 if (this.shouldNotLink(matchingTextNodes)) { 4545 continue; 4546 } 4547 this.createAutoLink(matchingTextNodes, matches[matchIndex].href); 4548 } 4549 return linkCreated; 4550 }, 4551 4552 shouldNotLink: function (textNodes) { 4553 var shouldNotLink = false; 4554 for (var i = 0; i < textNodes.length && shouldNotLink === false; i++) { 4555 // Do not link if the text node is either inside an anchor or inside span[data-auto-link] 4556 shouldNotLink = !!MediumEditor.util.traverseUp(textNodes[i], function (node) { 4557 return node.nodeName.toLowerCase() === 'a' || 4558 (node.getAttribute && node.getAttribute('data-auto-link') === 'true'); 4559 }); 4560 } 4561 return shouldNotLink; 4562 }, 4563 4564 findLinkableText: function (contenteditable) { 4565 var textContent = contenteditable.textContent, 4566 match = null, 4567 matches = []; 4568 4569 while ((match = LINK_REGEXP.exec(textContent)) !== null) { 4570 var matchOk = true, 4571 matchEnd = match.index + match[0].length; 4572 // If the regexp detected something as a link that has text immediately preceding/following it, bail out. 4573 matchOk = (match.index === 0 || WHITESPACE_CHARS.indexOf(textContent[match.index - 1]) !== -1) && 4574 (matchEnd === textContent.length || WHITESPACE_CHARS.indexOf(textContent[matchEnd]) !== -1); 4575 // If the regexp detected a bare domain that doesn't use one of our expected TLDs, bail out. 4576 matchOk = matchOk && (match[0].indexOf('/') !== -1 || 4577 KNOWN_TLDS_REGEXP.test(match[0].split('.').pop().split('?').shift())); 4578 4579 if (matchOk) { 4580 matches.push({ 4581 href: match[0], 4582 start: match.index, 4583 end: matchEnd 4584 }); 4585 } 4586 } 4587 return matches; 4588 }, 4589 4590 createAutoLink: function (textNodes, href) { 4591 href = MediumEditor.util.ensureUrlHasProtocol(href); 4592 var anchor = MediumEditor.util.createLink(this.document, textNodes, href, this.getEditorOption('targetBlank') ? '_blank' : null), 4593 span = this.document.createElement('span'); 4594 span.setAttribute('data-auto-link', 'true'); 4595 span.setAttribute('data-href', href); 4596 anchor.insertBefore(span, anchor.firstChild); 4597 while (anchor.childNodes.length > 1) { 4598 span.appendChild(anchor.childNodes[1]); 4599 } 4600 } 4601 4602 }); 4603 4604 MediumEditor.extensions.autoLink = AutoLink; 4605}()); 4606 4607(function () { 4608 'use strict'; 4609 4610 var CLASS_DRAG_OVER = 'medium-editor-dragover'; 4611 4612 function clearClassNames(element) { 4613 var editable = MediumEditor.util.getContainerEditorElement(element), 4614 existing = Array.prototype.slice.call(editable.parentElement.querySelectorAll('.' + CLASS_DRAG_OVER)); 4615 4616 existing.forEach(function (el) { 4617 el.classList.remove(CLASS_DRAG_OVER); 4618 }); 4619 } 4620 4621 var FileDragging = MediumEditor.Extension.extend({ 4622 name: 'fileDragging', 4623 4624 allowedTypes: ['image'], 4625 4626 init: function () { 4627 MediumEditor.Extension.prototype.init.apply(this, arguments); 4628 4629 this.subscribe('editableDrag', this.handleDrag.bind(this)); 4630 this.subscribe('editableDrop', this.handleDrop.bind(this)); 4631 }, 4632 4633 handleDrag: function (event) { 4634 event.preventDefault(); 4635 event.dataTransfer.dropEffect = 'copy'; 4636 4637 var target = event.target.classList ? event.target : event.target.parentElement; 4638 4639 // Ensure the class gets removed from anything that had it before 4640 clearClassNames(target); 4641 4642 if (event.type === 'dragover') { 4643 target.classList.add(CLASS_DRAG_OVER); 4644 } 4645 }, 4646 4647 handleDrop: function (event) { 4648 // Prevent file from opening in the current window 4649 event.preventDefault(); 4650 event.stopPropagation(); 4651 // Select the dropping target, and set the selection to the end of the target 4652 // https://github.com/yabwe/medium-editor/issues/980 4653 this.base.selectElement(event.target); 4654 var selection = this.base.exportSelection(); 4655 selection.start = selection.end; 4656 this.base.importSelection(selection); 4657 // IE9 does not support the File API, so prevent file from opening in the window 4658 // but also don't try to actually get the file 4659 if (event.dataTransfer.files) { 4660 Array.prototype.slice.call(event.dataTransfer.files).forEach(function (file) { 4661 if (this.isAllowedFile(file)) { 4662 if (file.type.match('image')) { 4663 this.insertImageFile(file); 4664 } 4665 } 4666 }, this); 4667 } 4668 4669 // Make sure we remove our class from everything 4670 clearClassNames(event.target); 4671 }, 4672 4673 isAllowedFile: function (file) { 4674 return this.allowedTypes.some(function (fileType) { 4675 return !!file.type.match(fileType); 4676 }); 4677 }, 4678 4679 insertImageFile: function (file) { 4680 if (typeof FileReader !== 'function') { 4681 return; 4682 } 4683 var fileReader = new FileReader(); 4684 fileReader.readAsDataURL(file); 4685 4686 // attach the onload event handler, makes it easier to listen in with jasmine 4687 fileReader.addEventListener('load', function (e) { 4688 var addImageElement = this.document.createElement('img'); 4689 addImageElement.src = e.target.result; 4690 MediumEditor.util.insertHTMLCommand(this.document, addImageElement.outerHTML); 4691 }.bind(this)); 4692 } 4693 }); 4694 4695 MediumEditor.extensions.fileDragging = FileDragging; 4696}()); 4697 4698(function () { 4699 'use strict'; 4700 4701 var KeyboardCommands = MediumEditor.Extension.extend({ 4702 name: 'keyboard-commands', 4703 4704 /* KeyboardCommands Options */ 4705 4706 /* commands: [Array] 4707 * Array of objects describing each command and the combination of keys that will trigger it 4708 * Required for each object: 4709 * command [String] (argument passed to editor.execAction()) 4710 * key [String] (keyboard character that triggers this command) 4711 * meta [boolean] (whether the ctrl/meta key has to be active or inactive) 4712 * shift [boolean] (whether the shift key has to be active or inactive) 4713 * alt [boolean] (whether the alt key has to be active or inactive) 4714 */ 4715 commands: [ 4716 { 4717 command: 'bold', 4718 key: 'B', 4719 meta: true, 4720 shift: false, 4721 alt: false 4722 }, 4723 { 4724 command: 'italic', 4725 key: 'I', 4726 meta: true, 4727 shift: false, 4728 alt: false 4729 }, 4730 { 4731 command: 'underline', 4732 key: 'U', 4733 meta: true, 4734 shift: false, 4735 alt: false 4736 } 4737 ], 4738 4739 init: function () { 4740 MediumEditor.Extension.prototype.init.apply(this, arguments); 4741 4742 this.subscribe('editableKeydown', this.handleKeydown.bind(this)); 4743 this.keys = {}; 4744 this.commands.forEach(function (command) { 4745 var keyCode = command.key.charCodeAt(0); 4746 if (!this.keys[keyCode]) { 4747 this.keys[keyCode] = []; 4748 } 4749 this.keys[keyCode].push(command); 4750 }, this); 4751 }, 4752 4753 handleKeydown: function (event) { 4754 var keyCode = MediumEditor.util.getKeyCode(event); 4755 if (!this.keys[keyCode]) { 4756 return; 4757 } 4758 4759 var isMeta = MediumEditor.util.isMetaCtrlKey(event), 4760 isShift = !!event.shiftKey, 4761 isAlt = !!event.altKey; 4762 4763 this.keys[keyCode].forEach(function (data) { 4764 if (data.meta === isMeta && 4765 data.shift === isShift && 4766 (data.alt === isAlt || 4767 undefined === data.alt)) { // TODO deprecated: remove check for undefined === data.alt when jumping to 6.0.0 4768 event.preventDefault(); 4769 event.stopPropagation(); 4770 4771 // command can be a function to execute 4772 if (typeof data.command === 'function') { 4773 data.command.apply(this); 4774 } 4775 // command can be false so the shortcut is just disabled 4776 else if (false !== data.command) { 4777 this.execAction(data.command); 4778 } 4779 } 4780 }, this); 4781 } 4782 }); 4783 4784 MediumEditor.extensions.keyboardCommands = KeyboardCommands; 4785}()); 4786 4787(function () { 4788 'use strict'; 4789 4790 var FontNameForm = MediumEditor.extensions.form.extend({ 4791 4792 name: 'fontname', 4793 action: 'fontName', 4794 aria: 'change font name', 4795 contentDefault: '±', // ± 4796 contentFA: '<i class="fa fa-font"></i>', 4797 4798 fonts: ['', 'Arial', 'Verdana', 'Times New Roman'], 4799 4800 init: function () { 4801 MediumEditor.extensions.form.prototype.init.apply(this, arguments); 4802 }, 4803 4804 // Called when the button the toolbar is clicked 4805 // Overrides ButtonExtension.handleClick 4806 handleClick: function (event) { 4807 event.preventDefault(); 4808 event.stopPropagation(); 4809 4810 if (!this.isDisplayed()) { 4811 // Get FontName of current selection (convert to string since IE returns this as number) 4812 var fontName = this.document.queryCommandValue('fontName') + ''; 4813 this.showForm(fontName); 4814 } 4815 4816 return false; 4817 }, 4818 4819 // Called by medium-editor to append form to the toolbar 4820 getForm: function () { 4821 if (!this.form) { 4822 this.form = this.createForm(); 4823 } 4824 return this.form; 4825 }, 4826 4827 // Used by medium-editor when the default toolbar is to be displayed 4828 isDisplayed: function () { 4829 return this.getForm().style.display === 'block'; 4830 }, 4831 4832 hideForm: function () { 4833 this.getForm().style.display = 'none'; 4834 this.getSelect().value = ''; 4835 }, 4836 4837 showForm: function (fontName) { 4838 var select = this.getSelect(); 4839 4840 this.base.saveSelection(); 4841 this.hideToolbarDefaultActions(); 4842 this.getForm().style.display = 'block'; 4843 this.setToolbarPosition(); 4844 4845 select.value = fontName || ''; 4846 select.focus(); 4847 }, 4848 4849 // Called by core when tearing down medium-editor (destroy) 4850 destroy: function () { 4851 if (!this.form) { 4852 return false; 4853 } 4854 4855 if (this.form.parentNode) { 4856 this.form.parentNode.removeChild(this.form); 4857 } 4858 4859 delete this.form; 4860 }, 4861 4862 // core methods 4863 4864 doFormSave: function () { 4865 this.base.restoreSelection(); 4866 this.base.checkSelection(); 4867 }, 4868 4869 doFormCancel: function () { 4870 this.base.restoreSelection(); 4871 this.clearFontName(); 4872 this.base.checkSelection(); 4873 }, 4874 4875 // form creation and event handling 4876 createForm: function () { 4877 var doc = this.document, 4878 form = doc.createElement('div'), 4879 select = doc.createElement('select'), 4880 close = doc.createElement('a'), 4881 save = doc.createElement('a'), 4882 option; 4883 4884 // Font Name Form (div) 4885 form.className = 'medium-editor-toolbar-form'; 4886 form.id = 'medium-editor-toolbar-form-fontname-' + this.getEditorId(); 4887 4888 // Handle clicks on the form itself 4889 this.on(form, 'click', this.handleFormClick.bind(this)); 4890 4891 // Add font names 4892 for (var i = 0; i<this.fonts.length; i++) { 4893 option = doc.createElement('option'); 4894 option.innerHTML = this.fonts[i]; 4895 option.value = this.fonts[i]; 4896 select.appendChild(option); 4897 } 4898 4899 select.className = 'medium-editor-toolbar-select'; 4900 form.appendChild(select); 4901 4902 // Handle typing in the textbox 4903 this.on(select, 'change', this.handleFontChange.bind(this)); 4904 4905 // Add save buton 4906 save.setAttribute('href', '#'); 4907 save.className = 'medium-editor-toobar-save'; 4908 save.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ? 4909 '<i class="fa fa-check"></i>' : 4910 '✓'; 4911 form.appendChild(save); 4912 4913 // Handle save button clicks (capture) 4914 this.on(save, 'click', this.handleSaveClick.bind(this), true); 4915 4916 // Add close button 4917 close.setAttribute('href', '#'); 4918 close.className = 'medium-editor-toobar-close'; 4919 close.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ? 4920 '<i class="fa fa-times"></i>' : 4921 '×'; 4922 form.appendChild(close); 4923 4924 // Handle close button clicks 4925 this.on(close, 'click', this.handleCloseClick.bind(this)); 4926 4927 return form; 4928 }, 4929 4930 getSelect: function () { 4931 return this.getForm().querySelector('select.medium-editor-toolbar-select'); 4932 }, 4933 4934 clearFontName: function () { 4935 MediumEditor.selection.getSelectedElements(this.document).forEach(function (el) { 4936 if (el.nodeName.toLowerCase() === 'font' && el.hasAttribute('face')) { 4937 el.removeAttribute('face'); 4938 } 4939 }); 4940 }, 4941 4942 handleFontChange: function () { 4943 var font = this.getSelect().value; 4944 if (font === '') { 4945 this.clearFontName(); 4946 } else { 4947 this.execAction('fontName', { value: font }); 4948 } 4949 }, 4950 4951 handleFormClick: function (event) { 4952 // make sure not to hide form when clicking inside the form 4953 event.stopPropagation(); 4954 }, 4955 4956 handleSaveClick: function (event) { 4957 // Clicking Save -> create the font size 4958 event.preventDefault(); 4959 this.doFormSave(); 4960 }, 4961 4962 handleCloseClick: function (event) { 4963 // Click Close -> close the form 4964 event.preventDefault(); 4965 this.doFormCancel(); 4966 } 4967 }); 4968 4969 MediumEditor.extensions.fontName = FontNameForm; 4970}()); 4971 4972(function () { 4973 'use strict'; 4974 4975 var FontSizeForm = MediumEditor.extensions.form.extend({ 4976 4977 name: 'fontsize', 4978 action: 'fontSize', 4979 aria: 'increase/decrease font size', 4980 contentDefault: '±', // ± 4981 contentFA: '<i class="fa fa-text-height"></i>', 4982 4983 init: function () { 4984 MediumEditor.extensions.form.prototype.init.apply(this, arguments); 4985 }, 4986 4987 // Called when the button the toolbar is clicked 4988 // Overrides ButtonExtension.handleClick 4989 handleClick: function (event) { 4990 event.preventDefault(); 4991 event.stopPropagation(); 4992 4993 if (!this.isDisplayed()) { 4994 // Get fontsize of current selection (convert to string since IE returns this as number) 4995 var fontSize = this.document.queryCommandValue('fontSize') + ''; 4996 this.showForm(fontSize); 4997 } 4998 4999 return false; 5000 }, 5001 5002 // Called by medium-editor to append form to the toolbar 5003 getForm: function () { 5004 if (!this.form) { 5005 this.form = this.createForm(); 5006 } 5007 return this.form; 5008 }, 5009 5010 // Used by medium-editor when the default toolbar is to be displayed 5011 isDisplayed: function () { 5012 return this.getForm().style.display === 'block'; 5013 }, 5014 5015 hideForm: function () { 5016 this.getForm().style.display = 'none'; 5017 this.getInput().value = ''; 5018 }, 5019 5020 showForm: function (fontSize) { 5021 var input = this.getInput(); 5022 5023 this.base.saveSelection(); 5024 this.hideToolbarDefaultActions(); 5025 this.getForm().style.display = 'block'; 5026 this.setToolbarPosition(); 5027 5028 input.value = fontSize || ''; 5029 input.focus(); 5030 }, 5031 5032 // Called by core when tearing down medium-editor (destroy) 5033 destroy: function () { 5034 if (!this.form) { 5035 return false; 5036 } 5037 5038 if (this.form.parentNode) { 5039 this.form.parentNode.removeChild(this.form); 5040 } 5041 5042 delete this.form; 5043 }, 5044 5045 // core methods 5046 5047 doFormSave: function () { 5048 this.base.restoreSelection(); 5049 this.base.checkSelection(); 5050 }, 5051 5052 doFormCancel: function () { 5053 this.base.restoreSelection(); 5054 this.clearFontSize(); 5055 this.base.checkSelection(); 5056 }, 5057 5058 // form creation and event handling 5059 createForm: function () { 5060 var doc = this.document, 5061 form = doc.createElement('div'), 5062 input = doc.createElement('input'), 5063 close = doc.createElement('a'), 5064 save = doc.createElement('a'); 5065 5066 // Font Size Form (div) 5067 form.className = 'medium-editor-toolbar-form'; 5068 form.id = 'medium-editor-toolbar-form-fontsize-' + this.getEditorId(); 5069 5070 // Handle clicks on the form itself 5071 this.on(form, 'click', this.handleFormClick.bind(this)); 5072 5073 // Add font size slider 5074 input.setAttribute('type', 'range'); 5075 input.setAttribute('min', '1'); 5076 input.setAttribute('max', '7'); 5077 input.className = 'medium-editor-toolbar-input'; 5078 form.appendChild(input); 5079 5080 // Handle typing in the textbox 5081 this.on(input, 'change', this.handleSliderChange.bind(this)); 5082 5083 // Add save buton 5084 save.setAttribute('href', '#'); 5085 save.className = 'medium-editor-toobar-save'; 5086 save.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ? 5087 '<i class="fa fa-check"></i>' : 5088 '✓'; 5089 form.appendChild(save); 5090 5091 // Handle save button clicks (capture) 5092 this.on(save, 'click', this.handleSaveClick.bind(this), true); 5093 5094 // Add close button 5095 close.setAttribute('href', '#'); 5096 close.className = 'medium-editor-toobar-close'; 5097 close.innerHTML = this.getEditorOption('buttonLabels') === 'fontawesome' ? 5098 '<i class="fa fa-times"></i>' : 5099 '×'; 5100 form.appendChild(close); 5101 5102 // Handle close button clicks 5103 this.on(close, 'click', this.handleCloseClick.bind(this)); 5104 5105 return form; 5106 }, 5107 5108 getInput: function () { 5109 return this.getForm().querySelector('input.medium-editor-toolbar-input'); 5110 }, 5111 5112 clearFontSize: function () { 5113 MediumEditor.selection.getSelectedElements(this.document).forEach(function (el) { 5114 if (el.nodeName.toLowerCase() === 'font' && el.hasAttribute('size')) { 5115 el.removeAttribute('size'); 5116 } 5117 }); 5118 }, 5119 5120 handleSliderChange: function () { 5121 var size = this.getInput().value; 5122 if (size === '4') { 5123 this.clearFontSize(); 5124 } else { 5125 this.execAction('fontSize', { value: size }); 5126 } 5127 }, 5128 5129 handleFormClick: function (event) { 5130 // make sure not to hide form when clicking inside the form 5131 event.stopPropagation(); 5132 }, 5133 5134 handleSaveClick: function (event) { 5135 // Clicking Save -> create the font size 5136 event.preventDefault(); 5137 this.doFormSave(); 5138 }, 5139 5140 handleCloseClick: function (event) { 5141 // Click Close -> close the form 5142 event.preventDefault(); 5143 this.doFormCancel(); 5144 } 5145 }); 5146 5147 MediumEditor.extensions.fontSize = FontSizeForm; 5148}()); 5149(function () { 5150 'use strict'; 5151 5152 /* Helpers and internal variables that don't need to be members of actual paste handler */ 5153 5154 var pasteBinDefaultContent = '%ME_PASTEBIN%', 5155 lastRange = null, 5156 keyboardPasteEditable = null, 5157 stopProp = function (event) { 5158 event.stopPropagation(); 5159 }; 5160 5161 /*jslint regexp: true*/ 5162 /* 5163 jslint does not allow character negation, because the negation 5164 will not match any unicode characters. In the regexes in this 5165 block, negation is used specifically to match the end of an html 5166 tag, and in fact unicode characters *should* be allowed. 5167 */ 5168 function createReplacements() { 5169 return [ 5170 // Remove anything but the contents within the BODY element 5171 [new RegExp(/^[\s\S]*<body[^>]*>\s*|\s*<\/body[^>]*>[\s\S]*$/g), ''], 5172 5173 // cleanup comments added by Chrome when pasting html 5174 [new RegExp(/<!--StartFragment-->|<!--EndFragment-->/g), ''], 5175 5176 // Trailing BR elements 5177 [new RegExp(/<br>$/i), ''], 5178 5179 // replace two bogus tags that begin pastes from google docs 5180 [new RegExp(/<[^>]*docs-internal-guid[^>]*>/gi), ''], 5181 [new RegExp(/<\/b>(<br[^>]*>)?$/gi), ''], 5182 5183 // un-html spaces and newlines inserted by OS X 5184 [new RegExp(/<span class="Apple-converted-space">\s+<\/span>/g), ' '], 5185 [new RegExp(/<br class="Apple-interchange-newline">/g), '<br>'], 5186 5187 // replace google docs italics+bold with a span to be replaced once the html is inserted 5188 [new RegExp(/<span[^>]*(font-style:italic;font-weight:(bold|700)|font-weight:(bold|700);font-style:italic)[^>]*>/gi), '<span class="replace-with italic bold">'], 5189 5190 // replace google docs italics with a span to be replaced once the html is inserted 5191 [new RegExp(/<span[^>]*font-style:italic[^>]*>/gi), '<span class="replace-with italic">'], 5192 5193 //[replace google docs bolds with a span to be replaced once the html is inserted 5194 [new RegExp(/<span[^>]*font-weight:(bold|700)[^>]*>/gi), '<span class="replace-with bold">'], 5195 5196 // replace manually entered b/i/a tags with real ones 5197 [new RegExp(/<(\/?)(i|b|a)>/gi), '<$1$2>'], 5198 5199 // replace manually a tags with real ones, converting smart-quotes from google docs 5200 [new RegExp(/<a(?:(?!href).)+href=(?:"|”|“|"|â|â)(((?!"|”|“|"|â|â).)*)(?:"|”|“|"|â|â)(?:(?!>).)*>/gi), '<a href="$1">'], 5201 5202 // Newlines between paragraphs in html have no syntactic value, 5203 // but then have a tendency to accidentally become additional paragraphs down the line 5204 [new RegExp(/<\/p>\n+/gi), '</p>'], 5205 [new RegExp(/\n+<p/gi), '<p'], 5206 5207 // Microsoft Word makes these odd tags, like <o:p></o:p> 5208 [new RegExp(/<\/?o:[a-z]*>/gi), ''], 5209 5210 // Microsoft Word adds some special elements around list items 5211 [new RegExp(/<!\[if !supportLists\]>(((?!<!).)*)<!\[endif]\>/gi), '$1'] 5212 ]; 5213 } 5214 /*jslint regexp: false*/ 5215 5216 /** 5217 * Gets various content types out of the Clipboard API. It will also get the 5218 * plain text using older IE and WebKit API. 5219 * 5220 * @param {event} event Event fired on paste. 5221 * @param {win} reference to window 5222 * @param {doc} reference to document 5223 * @return {Object} Object with mime types and data for those mime types. 5224 */ 5225 function getClipboardContent(event, win, doc) { 5226 var dataTransfer = event.clipboardData || win.clipboardData || doc.dataTransfer, 5227 data = {}; 5228 5229 if (!dataTransfer) { 5230 return data; 5231 } 5232 5233 // Use old WebKit/IE API 5234 if (dataTransfer.getData) { 5235 var legacyText = dataTransfer.getData('Text'); 5236 if (legacyText && legacyText.length > 0) { 5237 data['text/plain'] = legacyText; 5238 } 5239 } 5240 5241 if (dataTransfer.types) { 5242 for (var i = 0; i < dataTransfer.types.length; i++) { 5243 var contentType = dataTransfer.types[i]; 5244 data[contentType] = dataTransfer.getData(contentType); 5245 } 5246 } 5247 5248 return data; 5249 } 5250 5251 var PasteHandler = MediumEditor.Extension.extend({ 5252 /* Paste Options */ 5253 5254 /* forcePlainText: [boolean] 5255 * Forces pasting as plain text. 5256 */ 5257 forcePlainText: true, 5258 5259 /* cleanPastedHTML: [boolean] 5260 * cleans pasted content from different sources, like google docs etc. 5261 */ 5262 cleanPastedHTML: false, 5263 5264 /* preCleanReplacements: [Array] 5265 * custom pairs (2 element arrays) of RegExp and replacement text to use during past when 5266 * __forcePlainText__ or __cleanPastedHTML__ are `true` OR when calling `cleanPaste(text)` helper method. 5267 * These replacements are executed before any medium editor defined replacements. 5268 */ 5269 preCleanReplacements: [], 5270 5271 /* cleanReplacements: [Array] 5272 * custom pairs (2 element arrays) of RegExp and replacement text to use during paste when 5273 * __forcePlainText__ or __cleanPastedHTML__ are `true` OR when calling `cleanPaste(text)` helper method. 5274 * These replacements are executed after any medium editor defined replacements. 5275 */ 5276 cleanReplacements: [], 5277 5278 /* cleanAttrs:: [Array] 5279 * list of element attributes to remove during paste when __cleanPastedHTML__ is `true` or when 5280 * calling `cleanPaste(text)` or `pasteHTML(html, options)` helper methods. 5281 */ 5282 cleanAttrs: ['class', 'style', 'dir'], 5283 5284 /* cleanTags: [Array] 5285 * list of element tag names to remove during paste when __cleanPastedHTML__ is `true` or when 5286 * calling `cleanPaste(text)` or `pasteHTML(html, options)` helper methods. 5287 */ 5288 cleanTags: ['meta'], 5289 5290 /* unwrapTags: [Array] 5291 * list of element tag names to unwrap (remove the element tag but retain its child elements) 5292 * during paste when __cleanPastedHTML__ is `true` or when 5293 * calling `cleanPaste(text)` or `pasteHTML(html, options)` helper methods. 5294 */ 5295 unwrapTags: [], 5296 5297 init: function () { 5298 MediumEditor.Extension.prototype.init.apply(this, arguments); 5299 5300 if (this.forcePlainText || this.cleanPastedHTML) { 5301 this.subscribe('editableKeydown', this.handleKeydown.bind(this)); 5302 // We need access to the full event data in paste 5303 // so we can't use the editablePaste event here 5304 this.getEditorElements().forEach(function (element) { 5305 this.on(element, 'paste', this.handlePaste.bind(this)); 5306 }, this); 5307 this.subscribe('addElement', this.handleAddElement.bind(this)); 5308 } 5309 }, 5310 5311 handleAddElement: function (event, editable) { 5312 this.on(editable, 'paste', this.handlePaste.bind(this)); 5313 }, 5314 5315 destroy: function () { 5316 // Make sure pastebin is destroyed in case it's still around for some reason 5317 if (this.forcePlainText || this.cleanPastedHTML) { 5318 this.removePasteBin(); 5319 } 5320 }, 5321 5322 handlePaste: function (event, editable) { 5323 if (event.defaultPrevented) { 5324 return; 5325 } 5326 5327 var clipboardContent = getClipboardContent(event, this.window, this.document), 5328 pastedHTML = clipboardContent['text/html'], 5329 pastedPlain = clipboardContent['text/plain']; 5330 5331 if (this.window.clipboardData && event.clipboardData === undefined && !pastedHTML) { 5332 // If window.clipboardData exists, but event.clipboardData doesn't exist, 5333 // we're probably in IE. IE only has two possibilities for clipboard 5334 // data format: 'Text' and 'URL'. 5335 // 5336 // For IE, we'll fallback to 'Text' for text/html 5337 pastedHTML = pastedPlain; 5338 } 5339 5340 if (pastedHTML || pastedPlain) { 5341 event.preventDefault(); 5342 5343 this.doPaste(pastedHTML, pastedPlain, editable); 5344 } 5345 }, 5346 5347 doPaste: function (pastedHTML, pastedPlain, editable) { 5348 var paragraphs, 5349 html = '', 5350 p; 5351 5352 if (this.cleanPastedHTML && pastedHTML) { 5353 return this.cleanPaste(pastedHTML); 5354 } 5355 5356 if (!(this.getEditorOption('disableReturn') || (editable && editable.getAttribute('data-disable-return')))) { 5357 paragraphs = pastedPlain.split(/[\r\n]+/g); 5358 // If there are no \r\n in data, don't wrap in <p> 5359 if (paragraphs.length > 1) { 5360 for (p = 0; p < paragraphs.length; p += 1) { 5361 if (paragraphs[p] !== '') { 5362 html += '<p>' + MediumEditor.util.htmlEntities(paragraphs[p]) + '</p>'; 5363 } 5364 } 5365 } else { 5366 html = MediumEditor.util.htmlEntities(paragraphs[0]); 5367 } 5368 } else { 5369 html = MediumEditor.util.htmlEntities(pastedPlain); 5370 } 5371 MediumEditor.util.insertHTMLCommand(this.document, html); 5372 }, 5373 5374 handlePasteBinPaste: function (event) { 5375 if (event.defaultPrevented) { 5376 this.removePasteBin(); 5377 return; 5378 } 5379 5380 var clipboardContent = getClipboardContent(event, this.window, this.document), 5381 pastedHTML = clipboardContent['text/html'], 5382 pastedPlain = clipboardContent['text/plain'], 5383 editable = keyboardPasteEditable; 5384 5385 // If we have valid html already, or we're not in cleanPastedHTML mode 5386 // we can ignore the paste bin and just paste now 5387 if (!this.cleanPastedHTML || pastedHTML) { 5388 event.preventDefault(); 5389 this.removePasteBin(); 5390 this.doPaste(pastedHTML, pastedPlain, editable); 5391 5392 // The event handling code listens for paste on the editable element 5393 // in order to trigger the editablePaste event. Since this paste event 5394 // is happening on the pastebin, the event handling code never knows about it 5395 // So, we have to trigger editablePaste manually 5396 this.trigger('editablePaste', { currentTarget: editable, target: editable }, editable); 5397 return; 5398 } 5399 5400 // We need to look at the paste bin, so do a setTimeout to let the paste 5401 // fall through into the paste bin 5402 setTimeout(function () { 5403 // Only look for HTML if we're in cleanPastedHTML mode 5404 if (this.cleanPastedHTML) { 5405 // If clipboard didn't have HTML, try the paste bin 5406 pastedHTML = this.getPasteBinHtml(); 5407 } 5408 5409 // If we needed the paste bin, we're done with it now, remove it 5410 this.removePasteBin(); 5411 5412 // Handle the paste with the html from the paste bin 5413 this.doPaste(pastedHTML, pastedPlain, editable); 5414 5415 // The event handling code listens for paste on the editable element 5416 // in order to trigger the editablePaste event. Since this paste event 5417 // is happening on the pastebin, the event handling code never knows about it 5418 // So, we have to trigger editablePaste manually 5419 this.trigger('editablePaste', { currentTarget: editable, target: editable }, editable); 5420 }.bind(this), 0); 5421 }, 5422 5423 handleKeydown: function (event, editable) { 5424 // if it's not Ctrl+V, do nothing 5425 if (!(MediumEditor.util.isKey(event, MediumEditor.util.keyCode.V) && MediumEditor.util.isMetaCtrlKey(event))) { 5426 return; 5427 } 5428 5429 event.stopImmediatePropagation(); 5430 5431 this.removePasteBin(); 5432 this.createPasteBin(editable); 5433 }, 5434 5435 createPasteBin: function (editable) { 5436 var rects, 5437 range = MediumEditor.selection.getSelectionRange(this.document), 5438 top = this.window.pageYOffset; 5439 5440 keyboardPasteEditable = editable; 5441 5442 if (range) { 5443 rects = range.getClientRects(); 5444 5445 // on empty line, rects is empty so we grab information from the first container of the range 5446 if (rects.length) { 5447 top += rects[0].top; 5448 } else { 5449 top += range.startContainer.getBoundingClientRect().top; 5450 } 5451 } 5452 5453 lastRange = range; 5454 5455 var pasteBinElm = this.document.createElement('div'); 5456 pasteBinElm.id = this.pasteBinId = 'medium-editor-pastebin-' + (+Date.now()); 5457 pasteBinElm.setAttribute('style', 'border: 1px red solid; position: absolute; top: ' + top + 'px; width: 10px; height: 10px; overflow: hidden; opacity: 0'); 5458 pasteBinElm.setAttribute('contentEditable', true); 5459 pasteBinElm.innerHTML = pasteBinDefaultContent; 5460 5461 this.document.body.appendChild(pasteBinElm); 5462 5463 // avoid .focus() to stop other event (actually the paste event) 5464 this.on(pasteBinElm, 'focus', stopProp); 5465 this.on(pasteBinElm, 'focusin', stopProp); 5466 this.on(pasteBinElm, 'focusout', stopProp); 5467 5468 pasteBinElm.focus(); 5469 5470 MediumEditor.selection.selectNode(pasteBinElm, this.document); 5471 5472 if (!this.boundHandlePaste) { 5473 this.boundHandlePaste = this.handlePasteBinPaste.bind(this); 5474 } 5475 5476 this.on(pasteBinElm, 'paste', this.boundHandlePaste); 5477 }, 5478 5479 removePasteBin: function () { 5480 if (null !== lastRange) { 5481 MediumEditor.selection.selectRange(this.document, lastRange); 5482 lastRange = null; 5483 } 5484 5485 if (null !== keyboardPasteEditable) { 5486 keyboardPasteEditable = null; 5487 } 5488 5489 var pasteBinElm = this.getPasteBin(); 5490 if (!pasteBinElm) { 5491 return; 5492 } 5493 5494 if (pasteBinElm) { 5495 this.off(pasteBinElm, 'focus', stopProp); 5496 this.off(pasteBinElm, 'focusin', stopProp); 5497 this.off(pasteBinElm, 'focusout', stopProp); 5498 this.off(pasteBinElm, 'paste', this.boundHandlePaste); 5499 pasteBinElm.parentElement.removeChild(pasteBinElm); 5500 } 5501 }, 5502 5503 getPasteBin: function () { 5504 return this.document.getElementById(this.pasteBinId); 5505 }, 5506 5507 getPasteBinHtml: function () { 5508 var pasteBinElm = this.getPasteBin(); 5509 5510 if (!pasteBinElm) { 5511 return false; 5512 } 5513 5514 // WebKit has a nice bug where it clones the paste bin if you paste from for example notepad 5515 // so we need to force plain text mode in this case 5516 if (pasteBinElm.firstChild && pasteBinElm.firstChild.id === 'mcepastebin') { 5517 return false; 5518 } 5519 5520 var pasteBinHtml = pasteBinElm.innerHTML; 5521 5522 // If paste bin is empty try using plain text mode 5523 // since that is better than nothing right 5524 if (!pasteBinHtml || pasteBinHtml === pasteBinDefaultContent) { 5525 return false; 5526 } 5527 5528 return pasteBinHtml; 5529 }, 5530 5531 cleanPaste: function (text) { 5532 var i, elList, tmp, workEl, 5533 multiline = /<p|<br|<div/.test(text), 5534 replacements = [].concat( 5535 this.preCleanReplacements || [], 5536 createReplacements(), 5537 this.cleanReplacements || []); 5538 5539 for (i = 0; i < replacements.length; i += 1) { 5540 text = text.replace(replacements[i][0], replacements[i][1]); 5541 } 5542 5543 if (!multiline) { 5544 return this.pasteHTML(text); 5545 } 5546 5547 // create a temporary div to cleanup block elements 5548 tmp = this.document.createElement('div'); 5549 5550 // double br's aren't converted to p tags, but we want paragraphs. 5551 tmp.innerHTML = '<p>' + text.split('<br><br>').join('</p><p>') + '</p>'; 5552 5553 // block element cleanup 5554 elList = tmp.querySelectorAll('a,p,div,br'); 5555 for (i = 0; i < elList.length; i += 1) { 5556 workEl = elList[i]; 5557 5558 // Microsoft Word replaces some spaces with newlines. 5559 // While newlines between block elements are meaningless, newlines within 5560 // elements are sometimes actually spaces. 5561 workEl.innerHTML = workEl.innerHTML.replace(/\n/gi, ' '); 5562 5563 switch (workEl.nodeName.toLowerCase()) { 5564 case 'p': 5565 case 'div': 5566 this.filterCommonBlocks(workEl); 5567 break; 5568 case 'br': 5569 this.filterLineBreak(workEl); 5570 break; 5571 } 5572 } 5573 5574 this.pasteHTML(tmp.innerHTML); 5575 }, 5576 5577 pasteHTML: function (html, options) { 5578 options = MediumEditor.util.defaults({}, options, { 5579 cleanAttrs: this.cleanAttrs, 5580 cleanTags: this.cleanTags, 5581 unwrapTags: this.unwrapTags 5582 }); 5583 5584 var elList, workEl, i, fragmentBody, pasteBlock = this.document.createDocumentFragment(); 5585 5586 pasteBlock.appendChild(this.document.createElement('body')); 5587 5588 fragmentBody = pasteBlock.querySelector('body'); 5589 fragmentBody.innerHTML = html; 5590 5591 this.cleanupSpans(fragmentBody); 5592 5593 elList = fragmentBody.querySelectorAll('*'); 5594 for (i = 0; i < elList.length; i += 1) { 5595 workEl = elList[i]; 5596 5597 if ('a' === workEl.nodeName.toLowerCase() && this.getEditorOption('targetBlank')) { 5598 MediumEditor.util.setTargetBlank(workEl); 5599 } 5600 5601 MediumEditor.util.cleanupAttrs(workEl, options.cleanAttrs); 5602 MediumEditor.util.cleanupTags(workEl, options.cleanTags); 5603 MediumEditor.util.unwrapTags(workEl, options.unwrapTags); 5604 } 5605 5606 MediumEditor.util.insertHTMLCommand(this.document, fragmentBody.innerHTML.replace(/ /g, ' ')); 5607 }, 5608 5609 // TODO (6.0): Make this an internal helper instead of member of paste handler 5610 isCommonBlock: function (el) { 5611 return (el && (el.nodeName.toLowerCase() === 'p' || el.nodeName.toLowerCase() === 'div')); 5612 }, 5613 5614 // TODO (6.0): Make this an internal helper instead of member of paste handler 5615 filterCommonBlocks: function (el) { 5616 if (/^\s*$/.test(el.textContent) && el.parentNode) { 5617 el.parentNode.removeChild(el); 5618 } 5619 }, 5620 5621 // TODO (6.0): Make this an internal helper instead of member of paste handler 5622 filterLineBreak: function (el) { 5623 if (this.isCommonBlock(el.previousElementSibling)) { 5624 // remove stray br's following common block elements 5625 this.removeWithParent(el); 5626 } else if (this.isCommonBlock(el.parentNode) && (el.parentNode.firstChild === el || el.parentNode.lastChild === el)) { 5627 // remove br's just inside open or close tags of a div/p 5628 this.removeWithParent(el); 5629 } else if (el.parentNode && el.parentNode.childElementCount === 1 && el.parentNode.textContent === '') { 5630 // and br's that are the only child of elements other than div/p 5631 this.removeWithParent(el); 5632 } 5633 }, 5634 5635 // TODO (6.0): Make this an internal helper instead of member of paste handler 5636 // remove an element, including its parent, if it is the only element within its parent 5637 removeWithParent: function (el) { 5638 if (el && el.parentNode) { 5639 if (el.parentNode.parentNode && el.parentNode.childElementCount === 1) { 5640 el.parentNode.parentNode.removeChild(el.parentNode); 5641 } else { 5642 el.parentNode.removeChild(el); 5643 } 5644 } 5645 }, 5646 5647 // TODO (6.0): Make this an internal helper instead of member of paste handler 5648 cleanupSpans: function (containerEl) { 5649 var i, 5650 el, 5651 newEl, 5652 spans = containerEl.querySelectorAll('.replace-with'), 5653 isCEF = function (el) { 5654 return (el && el.nodeName !== '#text' && el.getAttribute('contenteditable') === 'false'); 5655 }; 5656 5657 for (i = 0; i < spans.length; i += 1) { 5658 el = spans[i]; 5659 newEl = this.document.createElement(el.classList.contains('bold') ? 'b' : 'i'); 5660 5661 if (el.classList.contains('bold') && el.classList.contains('italic')) { 5662 // add an i tag as well if this has both italics and bold 5663 newEl.innerHTML = '<i>' + el.innerHTML + '</i>'; 5664 } else { 5665 newEl.innerHTML = el.innerHTML; 5666 } 5667 el.parentNode.replaceChild(newEl, el); 5668 } 5669 5670 spans = containerEl.querySelectorAll('span'); 5671 for (i = 0; i < spans.length; i += 1) { 5672 el = spans[i]; 5673 5674 // bail if span is in contenteditable = false 5675 if (MediumEditor.util.traverseUp(el, isCEF)) { 5676 return false; 5677 } 5678 5679 // remove empty spans, replace others with their contents 5680 MediumEditor.util.unwrap(el, this.document); 5681 } 5682 } 5683 }); 5684 5685 MediumEditor.extensions.paste = PasteHandler; 5686}()); 5687 5688(function () { 5689 'use strict'; 5690 5691 var Placeholder = MediumEditor.Extension.extend({ 5692 name: 'placeholder', 5693 5694 /* Placeholder Options */ 5695 5696 /* text: [string] 5697 * Text to display in the placeholder 5698 */ 5699 text: 'Type your text', 5700 5701 /* hideOnClick: [boolean] 5702 * Should we hide the placeholder on click (true) or when user starts typing (false) 5703 */ 5704 hideOnClick: true, 5705 5706 init: function () { 5707 MediumEditor.Extension.prototype.init.apply(this, arguments); 5708 5709 this.initPlaceholders(); 5710 this.attachEventHandlers(); 5711 }, 5712 5713 initPlaceholders: function () { 5714 this.getEditorElements().forEach(this.initElement, this); 5715 }, 5716 5717 handleAddElement: function (event, editable) { 5718 this.initElement(editable); 5719 }, 5720 5721 initElement: function (el) { 5722 if (!el.getAttribute('data-placeholder')) { 5723 el.setAttribute('data-placeholder', this.text); 5724 } 5725 this.updatePlaceholder(el); 5726 }, 5727 5728 destroy: function () { 5729 this.getEditorElements().forEach(this.cleanupElement, this); 5730 }, 5731 5732 handleRemoveElement: function (event, editable) { 5733 this.cleanupElement(editable); 5734 }, 5735 5736 cleanupElement: function (el) { 5737 if (el.getAttribute('data-placeholder') === this.text) { 5738 el.removeAttribute('data-placeholder'); 5739 } 5740 }, 5741 5742 showPlaceholder: function (el) { 5743 if (el) { 5744 // https://github.com/yabwe/medium-editor/issues/234 5745 // In firefox, styling the placeholder with an absolutely positioned 5746 // pseudo element causes the cursor to appear in a bad location 5747 // when the element is completely empty, so apply a different class to 5748 // style it with a relatively positioned pseudo element 5749 if (MediumEditor.util.isFF && el.childNodes.length === 0) { 5750 el.classList.add('medium-editor-placeholder-relative'); 5751 el.classList.remove('medium-editor-placeholder'); 5752 } else { 5753 el.classList.add('medium-editor-placeholder'); 5754 el.classList.remove('medium-editor-placeholder-relative'); 5755 } 5756 } 5757 }, 5758 5759 hidePlaceholder: function (el) { 5760 if (el) { 5761 el.classList.remove('medium-editor-placeholder'); 5762 el.classList.remove('medium-editor-placeholder-relative'); 5763 } 5764 }, 5765 5766 updatePlaceholder: function (el, dontShow) { 5767 // If the element has content, hide the placeholder 5768 if (el.querySelector('img, blockquote, ul, ol, table') || (el.textContent.replace(/^\s+|\s+$/g, '') !== '')) { 5769 return this.hidePlaceholder(el); 5770 } 5771 5772 if (!dontShow) { 5773 this.showPlaceholder(el); 5774 } 5775 }, 5776 5777 attachEventHandlers: function () { 5778 if (this.hideOnClick) { 5779 // For the 'hideOnClick' option, the placeholder should always be hidden on focus 5780 this.subscribe('focus', this.handleFocus.bind(this)); 5781 } 5782 5783 // If the editor has content, it should always hide the placeholder 5784 this.subscribe('editableInput', this.handleInput.bind(this)); 5785 5786 // When the editor loses focus, check if the placeholder should be visible 5787 this.subscribe('blur', this.handleBlur.bind(this)); 5788 5789 // Need to know when elements are added/removed from the editor 5790 this.subscribe('addElement', this.handleAddElement.bind(this)); 5791 this.subscribe('removeElement', this.handleRemoveElement.bind(this)); 5792 }, 5793 5794 handleInput: function (event, element) { 5795 // If the placeholder should be hidden on focus and the 5796 // element has focus, don't show the placeholder 5797 var dontShow = this.hideOnClick && (element === this.base.getFocusedElement()); 5798 5799 // Editor's content has changed, check if the placeholder should be hidden 5800 this.updatePlaceholder(element, dontShow); 5801 }, 5802 5803 handleFocus: function (event, element) { 5804 // Editor has focus, hide the placeholder 5805 this.hidePlaceholder(element); 5806 }, 5807 5808 handleBlur: function (event, element) { 5809 // Editor has lost focus, check if the placeholder should be shown 5810 this.updatePlaceholder(element); 5811 } 5812 }); 5813 5814 MediumEditor.extensions.placeholder = Placeholder; 5815}()); 5816 5817(function () { 5818 'use strict'; 5819 5820 var Toolbar = MediumEditor.Extension.extend({ 5821 name: 'toolbar', 5822 5823 /* Toolbar Options */ 5824 5825 /* align: ['left'|'center'|'right'] 5826 * When the __static__ option is true, this aligns the static toolbar 5827 * relative to the medium-editor element. 5828 */ 5829 align: 'center', 5830 5831 /* allowMultiParagraphSelection: [boolean] 5832 * enables/disables whether the toolbar should be displayed when 5833 * selecting multiple paragraphs/block elements 5834 */ 5835 allowMultiParagraphSelection: true, 5836 5837 /* buttons: [Array] 5838 * the names of the set of buttons to display on the toolbar. 5839 */ 5840 buttons: ['bold', 'italic', 'underline', 'anchor', 'h2', 'h3', 'quote'], 5841 5842 /* diffLeft: [Number] 5843 * value in pixels to be added to the X axis positioning of the toolbar. 5844 */ 5845 diffLeft: 0, 5846 5847 /* diffTop: [Number] 5848 * value in pixels to be added to the Y axis positioning of the toolbar. 5849 */ 5850 diffTop: -10, 5851 5852 /* firstButtonClass: [string] 5853 * CSS class added to the first button in the toolbar. 5854 */ 5855 firstButtonClass: 'medium-editor-button-first', 5856 5857 /* lastButtonClass: [string] 5858 * CSS class added to the last button in the toolbar. 5859 */ 5860 lastButtonClass: 'medium-editor-button-last', 5861 5862 /* standardizeSelectionStart: [boolean] 5863 * enables/disables standardizing how the beginning of a range is decided 5864 * between browsers whenever the selected text is analyzed for updating toolbar buttons status. 5865 */ 5866 standardizeSelectionStart: false, 5867 5868 /* static: [boolean] 5869 * enable/disable the toolbar always displaying in the same location 5870 * relative to the medium-editor element. 5871 */ 5872 static: false, 5873 5874 /* sticky: [boolean] 5875 * When the __static__ option is true, this enables/disables the toolbar 5876 * "sticking" to the viewport and staying visible on the screen while 5877 * the page scrolls. 5878 */ 5879 sticky: false, 5880 5881 /* stickyTopOffset: [Number] 5882 * Value in pixel of the top offset above the toolbar 5883 */ 5884 stickyTopOffset: 0, 5885 5886 /* updateOnEmptySelection: [boolean] 5887 * When the __static__ option is true, this enables/disables updating 5888 * the state of the toolbar buttons even when the selection is collapsed 5889 * (there is no selection, just a cursor). 5890 */ 5891 updateOnEmptySelection: false, 5892 5893 /* relativeContainer: [node] 5894 * appending the toolbar to a given node instead of body 5895 */ 5896 relativeContainer: null, 5897 5898 init: function () { 5899 MediumEditor.Extension.prototype.init.apply(this, arguments); 5900 5901 this.initThrottledMethods(); 5902 5903 if (!this.relativeContainer) { 5904 this.getEditorOption('elementsContainer').appendChild(this.getToolbarElement()); 5905 } else { 5906 this.relativeContainer.appendChild(this.getToolbarElement()); 5907 } 5908 }, 5909 5910 // Helper method to execute method for every extension, but ignoring the toolbar extension 5911 forEachExtension: function (iterator, context) { 5912 return this.base.extensions.forEach(function (command) { 5913 if (command === this) { 5914 return; 5915 } 5916 return iterator.apply(context || this, arguments); 5917 }, this); 5918 }, 5919 5920 // Toolbar creation/deletion 5921 5922 createToolbar: function () { 5923 var toolbar = this.document.createElement('div'); 5924 5925 toolbar.id = 'medium-editor-toolbar-' + this.getEditorId(); 5926 toolbar.className = 'medium-editor-toolbar'; 5927 5928 if (this.static) { 5929 toolbar.className += ' static-toolbar'; 5930 } else if (this.relativeContainer) { 5931 toolbar.className += ' medium-editor-relative-toolbar'; 5932 } else { 5933 toolbar.className += ' medium-editor-stalker-toolbar'; 5934 } 5935 5936 toolbar.appendChild(this.createToolbarButtons()); 5937 5938 // Add any forms that extensions may have 5939 this.forEachExtension(function (extension) { 5940 if (extension.hasForm) { 5941 toolbar.appendChild(extension.getForm()); 5942 } 5943 }); 5944 5945 this.attachEventHandlers(); 5946 5947 return toolbar; 5948 }, 5949 5950 createToolbarButtons: function () { 5951 var ul = this.document.createElement('ul'), 5952 li, 5953 btn, 5954 buttons, 5955 extension, 5956 buttonName, 5957 buttonOpts; 5958 5959 ul.id = 'medium-editor-toolbar-actions' + this.getEditorId(); 5960 ul.className = 'medium-editor-toolbar-actions'; 5961 ul.style.display = 'block'; 5962 5963 this.buttons.forEach(function (button) { 5964 if (typeof button === 'string') { 5965 buttonName = button; 5966 buttonOpts = null; 5967 } else { 5968 buttonName = button.name; 5969 buttonOpts = button; 5970 } 5971 5972 // If the button already exists as an extension, it'll be returned 5973 // othwerise it'll create the default built-in button 5974 extension = this.base.addBuiltInExtension(buttonName, buttonOpts); 5975 5976 if (extension && typeof extension.getButton === 'function') { 5977 btn = extension.getButton(this.base); 5978 li = this.document.createElement('li'); 5979 if (MediumEditor.util.isElement(btn)) { 5980 li.appendChild(btn); 5981 } else { 5982 li.innerHTML = btn; 5983 } 5984 ul.appendChild(li); 5985 } 5986 }, this); 5987 5988 buttons = ul.querySelectorAll('button'); 5989 if (buttons.length > 0) { 5990 buttons[0].classList.add(this.firstButtonClass); 5991 buttons[buttons.length - 1].classList.add(this.lastButtonClass); 5992 } 5993 5994 return ul; 5995 }, 5996 5997 destroy: function () { 5998 if (this.toolbar) { 5999 if (this.toolbar.parentNode) { 6000 this.toolbar.parentNode.removeChild(this.toolbar); 6001 } 6002 delete this.toolbar; 6003 } 6004 }, 6005 6006 // Toolbar accessors 6007 6008 getInteractionElements: function () { 6009 return this.getToolbarElement(); 6010 }, 6011 6012 getToolbarElement: function () { 6013 if (!this.toolbar) { 6014 this.toolbar = this.createToolbar(); 6015 } 6016 6017 return this.toolbar; 6018 }, 6019 6020 getToolbarActionsElement: function () { 6021 return this.getToolbarElement().querySelector('.medium-editor-toolbar-actions'); 6022 }, 6023 6024 // Toolbar event handlers 6025 6026 initThrottledMethods: function () { 6027 // throttledPositionToolbar is throttled because: 6028 // - It will be called when the browser is resizing, which can fire many times very quickly 6029 // - For some event (like resize) a slight lag in UI responsiveness is OK and provides performance benefits 6030 this.throttledPositionToolbar = MediumEditor.util.throttle(function () { 6031 if (this.base.isActive) { 6032 this.positionToolbarIfShown(); 6033 } 6034 }.bind(this)); 6035 }, 6036 6037 attachEventHandlers: function () { 6038 // MediumEditor custom events for when user beings and ends interaction with a contenteditable and its elements 6039 this.subscribe('blur', this.handleBlur.bind(this)); 6040 this.subscribe('focus', this.handleFocus.bind(this)); 6041 6042 // Updating the state of the toolbar as things change 6043 this.subscribe('editableClick', this.handleEditableClick.bind(this)); 6044 this.subscribe('editableKeyup', this.handleEditableKeyup.bind(this)); 6045 6046 // Handle mouseup on document for updating the selection in the toolbar 6047 this.on(this.document.documentElement, 'mouseup', this.handleDocumentMouseup.bind(this)); 6048 6049 // Add a scroll event for sticky toolbar 6050 if (this.static && this.sticky) { 6051 // On scroll (capture), re-position the toolbar 6052 this.on(this.window, 'scroll', this.handleWindowScroll.bind(this), true); 6053 } 6054 6055 // On resize, re-position the toolbar 6056 this.on(this.window, 'resize', this.handleWindowResize.bind(this)); 6057 }, 6058 6059 handleWindowScroll: function () { 6060 this.positionToolbarIfShown(); 6061 }, 6062 6063 handleWindowResize: function () { 6064 this.throttledPositionToolbar(); 6065 }, 6066 6067 handleDocumentMouseup: function (event) { 6068 // Do not trigger checkState when mouseup fires over the toolbar 6069 if (event && 6070 event.target && 6071 MediumEditor.util.isDescendant(this.getToolbarElement(), event.target)) { 6072 return false; 6073 } 6074 this.checkState(); 6075 }, 6076 6077 handleEditableClick: function () { 6078 // Delay the call to checkState to handle bug where selection is empty 6079 // immediately after clicking inside a pre-existing selection 6080 setTimeout(function () { 6081 this.checkState(); 6082 }.bind(this), 0); 6083 }, 6084 6085 handleEditableKeyup: function () { 6086 this.checkState(); 6087 }, 6088 6089 handleBlur: function () { 6090 // Kill any previously delayed calls to hide the toolbar 6091 clearTimeout(this.hideTimeout); 6092 6093 // Blur may fire even if we have a selection, so we want to prevent any delayed showToolbar 6094 // calls from happening in this specific case 6095 clearTimeout(this.delayShowTimeout); 6096 6097 // Delay the call to hideToolbar to handle bug with multiple editors on the page at once 6098 this.hideTimeout = setTimeout(function () { 6099 this.hideToolbar(); 6100 }.bind(this), 1); 6101 }, 6102 6103 handleFocus: function () { 6104 this.checkState(); 6105 }, 6106 6107 // Hiding/showing toolbar 6108 6109 isDisplayed: function () { 6110 return this.getToolbarElement().classList.contains('medium-editor-toolbar-active'); 6111 }, 6112 6113 showToolbar: function () { 6114 clearTimeout(this.hideTimeout); 6115 if (!this.isDisplayed()) { 6116 this.getToolbarElement().classList.add('medium-editor-toolbar-active'); 6117 this.trigger('showToolbar', {}, this.base.getFocusedElement()); 6118 } 6119 }, 6120 6121 hideToolbar: function () { 6122 if (this.isDisplayed()) { 6123 this.getToolbarElement().classList.remove('medium-editor-toolbar-active'); 6124 this.trigger('hideToolbar', {}, this.base.getFocusedElement()); 6125 } 6126 }, 6127 6128 isToolbarDefaultActionsDisplayed: function () { 6129 return this.getToolbarActionsElement().style.display === 'block'; 6130 }, 6131 6132 hideToolbarDefaultActions: function () { 6133 if (this.isToolbarDefaultActionsDisplayed()) { 6134 this.getToolbarActionsElement().style.display = 'none'; 6135 } 6136 }, 6137 6138 showToolbarDefaultActions: function () { 6139 this.hideExtensionForms(); 6140 6141 if (!this.isToolbarDefaultActionsDisplayed()) { 6142 this.getToolbarActionsElement().style.display = 'block'; 6143 } 6144 6145 // Using setTimeout + options.delay because: 6146 // We will actually be displaying the toolbar, which should be controlled by options.delay 6147 this.delayShowTimeout = this.base.delay(function () { 6148 this.showToolbar(); 6149 }.bind(this)); 6150 }, 6151 6152 hideExtensionForms: function () { 6153 // Hide all extension forms 6154 this.forEachExtension(function (extension) { 6155 if (extension.hasForm && extension.isDisplayed()) { 6156 extension.hideForm(); 6157 } 6158 }); 6159 }, 6160 6161 // Responding to changes in user selection 6162 6163 // Checks for existance of multiple block elements in the current selection 6164 multipleBlockElementsSelected: function () { 6165 var regexEmptyHTMLTags = /<[^\/>][^>]*><\/[^>]+>/gim, // http://stackoverflow.com/questions/3129738/remove-empty-tags-using-regex 6166 regexBlockElements = new RegExp('<(' + MediumEditor.util.blockContainerElementNames.join('|') + ')[^>]*>', 'g'), 6167 selectionHTML = MediumEditor.selection.getSelectionHtml(this.document).replace(regexEmptyHTMLTags, ''), // Filter out empty blocks from selection 6168 hasMultiParagraphs = selectionHTML.match(regexBlockElements); // Find how many block elements are within the html 6169 6170 return !!hasMultiParagraphs && hasMultiParagraphs.length > 1; 6171 }, 6172 6173 modifySelection: function () { 6174 var selection = this.window.getSelection(), 6175 selectionRange = selection.getRangeAt(0); 6176 6177 /* 6178 * In firefox, there are cases (ie doubleclick of a word) where the selectionRange start 6179 * will be at the very end of an element. In other browsers, the selectionRange start 6180 * would instead be at the very beginning of an element that actually has content. 6181 * example: 6182 * <span>foo</span><span>bar</span> 6183 * 6184 * If the text 'bar' is selected, most browsers will have the selectionRange start at the beginning 6185 * of the 'bar' span. However, there are cases where firefox will have the selectionRange start 6186 * at the end of the 'foo' span. The contenteditable behavior will be ok, but if there are any 6187 * properties on the 'bar' span, they won't be reflected accurately in the toolbar 6188 * (ie 'Bold' button wouldn't be active) 6189 * 6190 * So, for cases where the selectionRange start is at the end of an element/node, find the next 6191 * adjacent text node that actually has content in it, and move the selectionRange start there. 6192 */ 6193 if (this.standardizeSelectionStart && 6194 selectionRange.startContainer.nodeValue && 6195 (selectionRange.startOffset === selectionRange.startContainer.nodeValue.length)) { 6196 var adjacentNode = MediumEditor.util.findAdjacentTextNodeWithContent(MediumEditor.selection.getSelectionElement(this.window), selectionRange.startContainer, this.document); 6197 if (adjacentNode) { 6198 var offset = 0; 6199 while (adjacentNode.nodeValue.substr(offset, 1).trim().length === 0) { 6200 offset = offset + 1; 6201 } 6202 selectionRange = MediumEditor.selection.select(this.document, adjacentNode, offset, 6203 selectionRange.endContainer, selectionRange.endOffset); 6204 } 6205 } 6206 }, 6207 6208 checkState: function () { 6209 if (this.base.preventSelectionUpdates) { 6210 return; 6211 } 6212 6213 // If no editable has focus OR selection is inside contenteditable = false 6214 // hide toolbar 6215 if (!this.base.getFocusedElement() || 6216 MediumEditor.selection.selectionInContentEditableFalse(this.window)) { 6217 return this.hideToolbar(); 6218 } 6219 6220 // If there's no selection element, selection element doesn't belong to this editor 6221 // or toolbar is disabled for this selection element 6222 // hide toolbar 6223 var selectionElement = MediumEditor.selection.getSelectionElement(this.window); 6224 if (!selectionElement || 6225 this.getEditorElements().indexOf(selectionElement) === -1 || 6226 selectionElement.getAttribute('data-disable-toolbar')) { 6227 return this.hideToolbar(); 6228 } 6229 6230 // Now we know there's a focused editable with a selection 6231 6232 // If the updateOnEmptySelection option is true, show the toolbar 6233 if (this.updateOnEmptySelection && this.static) { 6234 return this.showAndUpdateToolbar(); 6235 } 6236 6237 // If we don't have a 'valid' selection -> hide toolbar 6238 if (!MediumEditor.selection.selectionContainsContent(this.document) || 6239 (this.allowMultiParagraphSelection === false && this.multipleBlockElementsSelected())) { 6240 return this.hideToolbar(); 6241 } 6242 6243 this.showAndUpdateToolbar(); 6244 }, 6245 6246 // Updating the toolbar 6247 6248 showAndUpdateToolbar: function () { 6249 this.modifySelection(); 6250 this.setToolbarButtonStates(); 6251 this.trigger('positionToolbar', {}, this.base.getFocusedElement()); 6252 this.showToolbarDefaultActions(); 6253 this.setToolbarPosition(); 6254 }, 6255 6256 setToolbarButtonStates: function () { 6257 this.forEachExtension(function (extension) { 6258 if (typeof extension.isActive === 'function' && 6259 typeof extension.setInactive === 'function') { 6260 extension.setInactive(); 6261 } 6262 }); 6263 6264 this.checkActiveButtons(); 6265 }, 6266 6267 checkActiveButtons: function () { 6268 var manualStateChecks = [], 6269 queryState = null, 6270 selectionRange = MediumEditor.selection.getSelectionRange(this.document), 6271 parentNode, 6272 updateExtensionState = function (extension) { 6273 if (typeof extension.checkState === 'function') { 6274 extension.checkState(parentNode); 6275 } else if (typeof extension.isActive === 'function' && 6276 typeof extension.isAlreadyApplied === 'function' && 6277 typeof extension.setActive === 'function') { 6278 if (!extension.isActive() && extension.isAlreadyApplied(parentNode)) { 6279 extension.setActive(); 6280 } 6281 } 6282 }; 6283 6284 if (!selectionRange) { 6285 return; 6286 } 6287 6288 // Loop through all extensions 6289 this.forEachExtension(function (extension) { 6290 // For those extensions where we can use document.queryCommandState(), do so 6291 if (typeof extension.queryCommandState === 'function') { 6292 queryState = extension.queryCommandState(); 6293 // If queryCommandState returns a valid value, we can trust the browser 6294 // and don't need to do our manual checks 6295 if (queryState !== null) { 6296 if (queryState && typeof extension.setActive === 'function') { 6297 extension.setActive(); 6298 } 6299 return; 6300 } 6301 } 6302 // We can't use queryCommandState for this extension, so add to manualStateChecks 6303 manualStateChecks.push(extension); 6304 }); 6305 6306 parentNode = MediumEditor.selection.getSelectedParentElement(selectionRange); 6307 6308 // Make sure the selection parent isn't outside of the contenteditable 6309 if (!this.getEditorElements().some(function (element) { 6310 return MediumEditor.util.isDescendant(element, parentNode, true); 6311 })) { 6312 return; 6313 } 6314 6315 // Climb up the DOM and do manual checks for whether a certain extension is currently enabled for this node 6316 while (parentNode) { 6317 manualStateChecks.forEach(updateExtensionState); 6318 6319 // we can abort the search upwards if we leave the contentEditable element 6320 if (MediumEditor.util.isMediumEditorElement(parentNode)) { 6321 break; 6322 } 6323 parentNode = parentNode.parentNode; 6324 } 6325 }, 6326 6327 // Positioning toolbar 6328 6329 positionToolbarIfShown: function () { 6330 if (this.isDisplayed()) { 6331 this.setToolbarPosition(); 6332 } 6333 }, 6334 6335 setToolbarPosition: function () { 6336 var container = this.base.getFocusedElement(), 6337 selection = this.window.getSelection(); 6338 6339 // If there isn't a valid selection, bail 6340 if (!container) { 6341 return this; 6342 } 6343 6344 if (this.static || !selection.isCollapsed) { 6345 this.showToolbar(); 6346 6347 // we don't need any absolute positioning if relativeContainer is set 6348 if (!this.relativeContainer) { 6349 if (this.static) { 6350 this.positionStaticToolbar(container); 6351 } else { 6352 this.positionToolbar(selection); 6353 } 6354 } 6355 6356 this.trigger('positionedToolbar', {}, this.base.getFocusedElement()); 6357 } 6358 }, 6359 6360 positionStaticToolbar: function (container) { 6361 // position the toolbar at left 0, so we can get the real width of the toolbar 6362 this.getToolbarElement().style.left = '0'; 6363 6364 // document.documentElement for IE 9 6365 var scrollTop = (this.document.documentElement && this.document.documentElement.scrollTop) || this.document.body.scrollTop, 6366 windowWidth = this.window.innerWidth, 6367 toolbarElement = this.getToolbarElement(), 6368 containerRect = container.getBoundingClientRect(), 6369 containerTop = containerRect.top + scrollTop, 6370 containerCenter = (containerRect.left + (containerRect.width / 2)), 6371 toolbarHeight = toolbarElement.offsetHeight, 6372 toolbarWidth = toolbarElement.offsetWidth, 6373 halfOffsetWidth = toolbarWidth / 2, 6374 targetLeft; 6375 6376 if (this.sticky) { 6377 // If it's beyond the height of the editor, position it at the bottom of the editor 6378 if (scrollTop > (containerTop + container.offsetHeight - toolbarHeight - this.stickyTopOffset)) { 6379 toolbarElement.style.top = (containerTop + container.offsetHeight - toolbarHeight) + 'px'; 6380 toolbarElement.classList.remove('medium-editor-sticky-toolbar'); 6381 // Stick the toolbar to the top of the window 6382 } else if (scrollTop > (containerTop - toolbarHeight - this.stickyTopOffset)) { 6383 toolbarElement.classList.add('medium-editor-sticky-toolbar'); 6384 toolbarElement.style.top = this.stickyTopOffset + 'px'; 6385 // Normal static toolbar position 6386 } else { 6387 toolbarElement.classList.remove('medium-editor-sticky-toolbar'); 6388 toolbarElement.style.top = containerTop - toolbarHeight + 'px'; 6389 } 6390 } else { 6391 toolbarElement.style.top = containerTop - toolbarHeight + 'px'; 6392 } 6393 6394 switch (this.align) { 6395 case 'left': 6396 targetLeft = containerRect.left; 6397 break; 6398 6399 case 'right': 6400 targetLeft = containerRect.right - toolbarWidth; 6401 break; 6402 6403 case 'center': 6404 targetLeft = containerCenter - halfOffsetWidth; 6405 break; 6406 } 6407 6408 if (targetLeft < 0) { 6409 targetLeft = 0; 6410 } else if ((targetLeft + toolbarWidth) > windowWidth) { 6411 targetLeft = (windowWidth - Math.ceil(toolbarWidth) - 1); 6412 } 6413 6414 toolbarElement.style.left = targetLeft + 'px'; 6415 }, 6416 6417 positionToolbar: function (selection) { 6418 // position the toolbar at left 0, so we can get the real width of the toolbar 6419 this.getToolbarElement().style.left = '0'; 6420 this.getToolbarElement().style.right = 'initial'; 6421 6422 var range = selection.getRangeAt(0), 6423 boundary = range.getBoundingClientRect(); 6424 6425 // Handle selections with just images 6426 if (!boundary || ((boundary.height === 0 && boundary.width === 0) && range.startContainer === range.endContainer)) { 6427 // If there's a nested image, use that for the bounding rectangle 6428 if (range.startContainer.nodeType === 1 && range.startContainer.querySelector('img')) { 6429 boundary = range.startContainer.querySelector('img').getBoundingClientRect(); 6430 } else { 6431 boundary = range.startContainer.getBoundingClientRect(); 6432 } 6433 } 6434 6435 var containerWidth = this.window.innerWidth, 6436 toolbarElement = this.getToolbarElement(), 6437 toolbarHeight = toolbarElement.offsetHeight, 6438 toolbarWidth = toolbarElement.offsetWidth, 6439 halfOffsetWidth = toolbarWidth / 2, 6440 buttonHeight = 50, 6441 defaultLeft = this.diffLeft - halfOffsetWidth, 6442 elementsContainer = this.getEditorOption('elementsContainer'), 6443 elementsContainerAbsolute = ['absolute', 'fixed'].indexOf(window.getComputedStyle(elementsContainer).getPropertyValue('position')) > -1, 6444 positions = {}, 6445 relativeBoundary = {}, 6446 middleBoundary, elementsContainerBoundary; 6447 6448 // If container element is absolute / fixed, recalculate boundaries to be relative to the container 6449 if (elementsContainerAbsolute) { 6450 elementsContainerBoundary = elementsContainer.getBoundingClientRect(); 6451 ['top', 'left'].forEach(function (key) { 6452 relativeBoundary[key] = boundary[key] - elementsContainerBoundary[key]; 6453 }); 6454 6455 relativeBoundary.width = boundary.width; 6456 relativeBoundary.height = boundary.height; 6457 boundary = relativeBoundary; 6458 6459 containerWidth = elementsContainerBoundary.width; 6460 6461 // Adjust top position according to container scroll position 6462 positions.top = elementsContainer.scrollTop; 6463 } else { 6464 // Adjust top position according to window scroll position 6465 positions.top = this.window.pageYOffset; 6466 } 6467 6468 middleBoundary = boundary.left + boundary.width / 2; 6469 positions.top += boundary.top - toolbarHeight; 6470 6471 if (boundary.top < buttonHeight) { 6472 toolbarElement.classList.add('medium-toolbar-arrow-over'); 6473 toolbarElement.classList.remove('medium-toolbar-arrow-under'); 6474 positions.top += buttonHeight + boundary.height - this.diffTop; 6475 } else { 6476 toolbarElement.classList.add('medium-toolbar-arrow-under'); 6477 toolbarElement.classList.remove('medium-toolbar-arrow-over'); 6478 positions.top += this.diffTop; 6479 } 6480 6481 if (middleBoundary < halfOffsetWidth) { 6482 positions.left = defaultLeft + halfOffsetWidth; 6483 positions.right = 'initial'; 6484 } else if ((containerWidth - middleBoundary) < halfOffsetWidth) { 6485 positions.left = 'auto'; 6486 positions.right = 0; 6487 } else { 6488 positions.left = defaultLeft + middleBoundary; 6489 positions.right = 'initial'; 6490 } 6491 6492 ['top', 'left', 'right'].forEach(function (key) { 6493 toolbarElement.style[key] = positions[key] + (isNaN(positions[key]) ? '' : 'px'); 6494 }); 6495 } 6496 }); 6497 6498 MediumEditor.extensions.toolbar = Toolbar; 6499}()); 6500 6501(function () { 6502 'use strict'; 6503 6504 var ImageDragging = MediumEditor.Extension.extend({ 6505 init: function () { 6506 MediumEditor.Extension.prototype.init.apply(this, arguments); 6507 6508 this.subscribe('editableDrag', this.handleDrag.bind(this)); 6509 this.subscribe('editableDrop', this.handleDrop.bind(this)); 6510 }, 6511 6512 handleDrag: function (event) { 6513 var className = 'medium-editor-dragover'; 6514 event.preventDefault(); 6515 event.dataTransfer.dropEffect = 'copy'; 6516 6517 if (event.type === 'dragover') { 6518 event.target.classList.add(className); 6519 } else if (event.type === 'dragleave') { 6520 event.target.classList.remove(className); 6521 } 6522 }, 6523 6524 handleDrop: function (event) { 6525 var className = 'medium-editor-dragover', 6526 files; 6527 event.preventDefault(); 6528 event.stopPropagation(); 6529 6530 // IE9 does not support the File API, so prevent file from opening in a new window 6531 // but also don't try to actually get the file 6532 if (event.dataTransfer.files) { 6533 files = Array.prototype.slice.call(event.dataTransfer.files, 0); 6534 files.some(function (file) { 6535 if (file.type.match('image')) { 6536 var fileReader, id; 6537 fileReader = new FileReader(); 6538 fileReader.readAsDataURL(file); 6539 6540 id = 'medium-img-' + (+new Date()); 6541 MediumEditor.util.insertHTMLCommand(this.document, '<img class="medium-editor-image-loading" id="' + id + '" />'); 6542 6543 fileReader.onload = function () { 6544 var img = this.document.getElementById(id); 6545 if (img) { 6546 img.removeAttribute('id'); 6547 img.removeAttribute('class'); 6548 img.src = fileReader.result; 6549 } 6550 }.bind(this); 6551 } 6552 }.bind(this)); 6553 } 6554 event.target.classList.remove(className); 6555 } 6556 }); 6557 6558 MediumEditor.extensions.imageDragging = ImageDragging; 6559}()); 6560 6561(function () { 6562 'use strict'; 6563 6564 // Event handlers that shouldn't be exposed externally 6565 6566 function handleDisableExtraSpaces(event) { 6567 var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument), 6568 textContent = node.textContent, 6569 caretPositions = MediumEditor.selection.getCaretOffsets(node); 6570 6571 if ((textContent[caretPositions.left - 1] === undefined) || (textContent[caretPositions.left - 1].trim() === '') || (textContent[caretPositions.left] !== undefined && textContent[caretPositions.left].trim() === '')) { 6572 event.preventDefault(); 6573 } 6574 } 6575 6576 function handleDisabledEnterKeydown(event, element) { 6577 if (this.options.disableReturn || element.getAttribute('data-disable-return')) { 6578 event.preventDefault(); 6579 } else if (this.options.disableDoubleReturn || element.getAttribute('data-disable-double-return')) { 6580 var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument); 6581 6582 // if current text selection is empty OR previous sibling text is empty OR it is not a list 6583 if ((node && node.textContent.trim() === '' && node.nodeName.toLowerCase() !== 'li') || 6584 (node.previousElementSibling && node.previousElementSibling.nodeName.toLowerCase() !== 'br' && 6585 node.previousElementSibling.textContent.trim() === '')) { 6586 event.preventDefault(); 6587 } 6588 } 6589 } 6590 6591 function handleTabKeydown(event) { 6592 // Override tab only for pre nodes 6593 var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument), 6594 tag = node && node.nodeName.toLowerCase(); 6595 6596 if (tag === 'pre') { 6597 event.preventDefault(); 6598 MediumEditor.util.insertHTMLCommand(this.options.ownerDocument, ' '); 6599 } 6600 6601 // Tab to indent list structures! 6602 if (MediumEditor.util.isListItem(node)) { 6603 event.preventDefault(); 6604 6605 // If Shift is down, outdent, otherwise indent 6606 if (event.shiftKey) { 6607 this.options.ownerDocument.execCommand('outdent', false, null); 6608 } else { 6609 this.options.ownerDocument.execCommand('indent', false, null); 6610 } 6611 } 6612 } 6613 6614 function handleBlockDeleteKeydowns(event) { 6615 var p, node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument), 6616 tagName = node.nodeName.toLowerCase(), 6617 isEmpty = /^(\s+|<br\/?>)?$/i, 6618 isHeader = /h\d/i; 6619 6620 if (MediumEditor.util.isKey(event, [MediumEditor.util.keyCode.BACKSPACE, MediumEditor.util.keyCode.ENTER]) && 6621 // has a preceeding sibling 6622 node.previousElementSibling && 6623 // in a header 6624 isHeader.test(tagName) && 6625 // at the very end of the block 6626 MediumEditor.selection.getCaretOffsets(node).left === 0) { 6627 if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) && isEmpty.test(node.previousElementSibling.innerHTML)) { 6628 // backspacing the begining of a header into an empty previous element will 6629 // change the tagName of the current node to prevent one 6630 // instead delete previous node and cancel the event. 6631 node.previousElementSibling.parentNode.removeChild(node.previousElementSibling); 6632 event.preventDefault(); 6633 } else if (!this.options.disableDoubleReturn && MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER)) { 6634 // hitting return in the begining of a header will create empty header elements before the current one 6635 // instead, make "<p><br></p>" element, which are what happens if you hit return in an empty paragraph 6636 p = this.options.ownerDocument.createElement('p'); 6637 p.innerHTML = '<br>'; 6638 node.previousElementSibling.parentNode.insertBefore(p, node); 6639 event.preventDefault(); 6640 } 6641 } else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.DELETE) && 6642 // between two sibling elements 6643 node.nextElementSibling && 6644 node.previousElementSibling && 6645 // not in a header 6646 !isHeader.test(tagName) && 6647 // in an empty tag 6648 isEmpty.test(node.innerHTML) && 6649 // when the next tag *is* a header 6650 isHeader.test(node.nextElementSibling.nodeName.toLowerCase())) { 6651 // hitting delete in an empty element preceding a header, ex: 6652 // <p>[CURSOR]</p><h1>Header</h1> 6653 // Will cause the h1 to become a paragraph. 6654 // Instead, delete the paragraph node and move the cursor to the begining of the h1 6655 6656 // remove node and move cursor to start of header 6657 MediumEditor.selection.moveCursor(this.options.ownerDocument, node.nextElementSibling); 6658 6659 node.previousElementSibling.parentNode.removeChild(node); 6660 6661 event.preventDefault(); 6662 } else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) && 6663 tagName === 'li' && 6664 // hitting backspace inside an empty li 6665 isEmpty.test(node.innerHTML) && 6666 // is first element (no preceeding siblings) 6667 !node.previousElementSibling && 6668 // parent also does not have a sibling 6669 !node.parentElement.previousElementSibling && 6670 // is not the only li in a list 6671 node.nextElementSibling && 6672 node.nextElementSibling.nodeName.toLowerCase() === 'li') { 6673 // backspacing in an empty first list element in the first list (with more elements) ex: 6674 // <ul><li>[CURSOR]</li><li>List Item 2</li></ul> 6675 // will remove the first <li> but add some extra element before (varies based on browser) 6676 // Instead, this will: 6677 // 1) remove the list element 6678 // 2) create a paragraph before the list 6679 // 3) move the cursor into the paragraph 6680 6681 // create a paragraph before the list 6682 p = this.options.ownerDocument.createElement('p'); 6683 p.innerHTML = '<br>'; 6684 node.parentElement.parentElement.insertBefore(p, node.parentElement); 6685 6686 // move the cursor into the new paragraph 6687 MediumEditor.selection.moveCursor(this.options.ownerDocument, p); 6688 6689 // remove the list element 6690 node.parentElement.removeChild(node); 6691 6692 event.preventDefault(); 6693 } else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) && 6694 (MediumEditor.util.getClosestTag(node, 'blockquote') !== false) && 6695 MediumEditor.selection.getCaretOffsets(node).left === 0) { 6696 6697 // when cursor is at the begining of the element and the element is <blockquote> 6698 // then pressing backspace key should change the <blockquote> to a <p> tag 6699 event.preventDefault(); 6700 MediumEditor.util.execFormatBlock(this.options.ownerDocument, 'p'); 6701 } else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) && 6702 (MediumEditor.util.getClosestTag(node, 'blockquote') !== false) && 6703 MediumEditor.selection.getCaretOffsets(node).right === 0) { 6704 6705 // when cursor is at the end of <blockquote>, 6706 // then pressing enter key should create <p> tag, not <blockquote> 6707 p = this.options.ownerDocument.createElement('p'); 6708 p.innerHTML = '<br>'; 6709 node.parentElement.insertBefore(p, node.nextSibling); 6710 6711 // move the cursor into the new paragraph 6712 MediumEditor.selection.moveCursor(this.options.ownerDocument, p); 6713 6714 event.preventDefault(); 6715 } else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) && 6716 MediumEditor.util.isMediumEditorElement(node.parentElement) && 6717 !node.previousElementSibling && 6718 node.nextElementSibling && 6719 isEmpty.test(node.innerHTML)) { 6720 6721 // when cursor is in the first element, it's empty and user presses backspace, 6722 // do delete action instead to get rid of the first element and move caret to 2nd 6723 event.preventDefault(); 6724 MediumEditor.selection.moveCursor(this.options.ownerDocument, node.nextSibling); 6725 node.parentElement.removeChild(node); 6726 } 6727 } 6728 6729 function handleKeyup(event) { 6730 var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument), 6731 tagName; 6732 6733 if (!node) { 6734 return; 6735 } 6736 6737 // https://github.com/yabwe/medium-editor/issues/994 6738 // Firefox thrown an error when calling `formatBlock` on an empty editable blockContainer that's not a <div> 6739 if (MediumEditor.util.isMediumEditorElement(node) && node.children.length === 0 && !MediumEditor.util.isBlockContainer(node)) { 6740 this.options.ownerDocument.execCommand('formatBlock', false, 'p'); 6741 } 6742 6743 // https://github.com/yabwe/medium-editor/issues/834 6744 // https://github.com/yabwe/medium-editor/pull/382 6745 // Don't call format block if this is a block element (ie h1, figCaption, etc.) 6746 if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) && 6747 !MediumEditor.util.isListItem(node) && 6748 !MediumEditor.util.isBlockContainer(node)) { 6749 6750 tagName = node.nodeName.toLowerCase(); 6751 // For anchor tags, unlink 6752 if (tagName === 'a') { 6753 this.options.ownerDocument.execCommand('unlink', false, null); 6754 } else if (!event.shiftKey && !event.ctrlKey) { 6755 this.options.ownerDocument.execCommand('formatBlock', false, 'p'); 6756 } 6757 } 6758 6759 if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) && node.nodeName.toLowerCase() === 'div') { 6760 this.options.ownerDocument.execCommand('formatBlock', false, 'p'); 6761 } 6762 } 6763 6764 function handleEditableInput(event, editable) { 6765 var textarea = editable.parentNode.querySelector('textarea[medium-editor-textarea-id="' + editable.getAttribute('medium-editor-textarea-id') + '"]'); 6766 if (textarea) { 6767 var index = this.elements.indexOf(editable); 6768 if (index !== -1) { 6769 textarea.value = this.getContent(index); 6770 } 6771 } 6772 } 6773 6774 // Internal helper methods which shouldn't be exposed externally 6775 6776 function addToEditors(win) { 6777 if (!win._mediumEditors) { 6778 // To avoid breaking users who are assuming that the unique id on 6779 // medium-editor elements will start at 1, inserting a 'null' in the 6780 // array so the unique-id can always map to the index of the editor instance 6781 win._mediumEditors = [null]; 6782 } 6783 6784 // If this already has a unique id, re-use it 6785 if (!this.id) { 6786 this.id = win._mediumEditors.length; 6787 } 6788 6789 win._mediumEditors[this.id] = this; 6790 } 6791 6792 function removeFromEditors(win) { 6793 if (!win._mediumEditors || !win._mediumEditors[this.id]) { 6794 return; 6795 } 6796 6797 /* Setting the instance to null in the array instead of deleting it allows: 6798 * 1) Each instance to preserve its own unique-id, even after being destroyed 6799 * and initialized again 6800 * 2) The unique-id to always correspond to an index in the array of medium-editor 6801 * instances. Thus, we will be able to look at a contenteditable, and determine 6802 * which instance it belongs to, by indexing into the global array. 6803 */ 6804 win._mediumEditors[this.id] = null; 6805 } 6806 6807 function createElementsArray(selector, doc, filterEditorElements) { 6808 var elements = []; 6809 6810 if (!selector) { 6811 selector = []; 6812 } 6813 // If string, use as query selector 6814 if (typeof selector === 'string') { 6815 selector = doc.querySelectorAll(selector); 6816 } 6817 // If element, put into array 6818 if (MediumEditor.util.isElement(selector)) { 6819 selector = [selector]; 6820 } 6821 6822 if (filterEditorElements) { 6823 // Remove elements that have already been initialized by the editor 6824 // selecotr might not be an array (ie NodeList) so use for loop 6825 for (var i = 0; i < selector.length; i++) { 6826 var el = selector[i]; 6827 if (MediumEditor.util.isElement(el) && 6828 !el.getAttribute('data-medium-editor-element') && 6829 !el.getAttribute('medium-editor-textarea-id')) { 6830 elements.push(el); 6831 } 6832 } 6833 } else { 6834 // Convert NodeList (or other array like object) into an array 6835 elements = Array.prototype.slice.apply(selector); 6836 } 6837 6838 return elements; 6839 } 6840 6841 function cleanupTextareaElement(element) { 6842 var textarea = element.parentNode.querySelector('textarea[medium-editor-textarea-id="' + element.getAttribute('medium-editor-textarea-id') + '"]'); 6843 if (textarea) { 6844 // Un-hide the textarea 6845 textarea.classList.remove('medium-editor-hidden'); 6846 textarea.removeAttribute('medium-editor-textarea-id'); 6847 } 6848 if (element.parentNode) { 6849 element.parentNode.removeChild(element); 6850 } 6851 } 6852 6853 function setExtensionDefaults(extension, defaults) { 6854 Object.keys(defaults).forEach(function (prop) { 6855 if (extension[prop] === undefined) { 6856 extension[prop] = defaults[prop]; 6857 } 6858 }); 6859 return extension; 6860 } 6861 6862 function initExtension(extension, name, instance) { 6863 var extensionDefaults = { 6864 'window': instance.options.contentWindow, 6865 'document': instance.options.ownerDocument, 6866 'base': instance 6867 }; 6868 6869 // Add default options into the extension 6870 extension = setExtensionDefaults(extension, extensionDefaults); 6871 6872 // Call init on the extension 6873 if (typeof extension.init === 'function') { 6874 extension.init(); 6875 } 6876 6877 // Set extension name (if not already set) 6878 if (!extension.name) { 6879 extension.name = name; 6880 } 6881 return extension; 6882 } 6883 6884 function isToolbarEnabled() { 6885 return this.options.toolbar !== false; 6886 } 6887 6888 function isAnchorPreviewEnabled() { 6889 // If toolbar is disabled, don't add 6890 if (!isToolbarEnabled.call(this)) { 6891 return false; 6892 } 6893 6894 return this.options.anchorPreview !== false; 6895 } 6896 6897 function isPlaceholderEnabled() { 6898 return this.options.placeholder !== false; 6899 } 6900 6901 function isAutoLinkEnabled() { 6902 return this.options.autoLink !== false; 6903 } 6904 6905 function isImageDraggingEnabled() { 6906 return this.options.imageDragging !== false; 6907 } 6908 6909 function isKeyboardCommandsEnabled() { 6910 return this.options.keyboardCommands !== false; 6911 } 6912 6913 function shouldUseFileDraggingExtension() { 6914 // Since the file-dragging extension replaces the image-dragging extension, 6915 // we need to check if the user passed an overrided image-dragging extension. 6916 // If they have, to avoid breaking users, we won't use file-dragging extension. 6917 return !this.options.extensions['imageDragging']; 6918 } 6919 6920 function createContentEditable(textarea) { 6921 var div = this.options.ownerDocument.createElement('div'), 6922 now = Date.now(), 6923 uniqueId = 'medium-editor-' + now, 6924 atts = textarea.attributes; 6925 6926 // Some browsers can move pretty fast, since we're using a timestamp 6927 // to make a unique-id, ensure that the id is actually unique on the page 6928 while (this.options.ownerDocument.getElementById(uniqueId)) { 6929 now++; 6930 uniqueId = 'medium-editor-' + now; 6931 } 6932 6933 div.className = textarea.className; 6934 div.id = uniqueId; 6935 div.innerHTML = textarea.value; 6936 6937 textarea.setAttribute('medium-editor-textarea-id', uniqueId); 6938 6939 // re-create all attributes from the textearea to the new created div 6940 for (var i = 0, n = atts.length; i < n; i++) { 6941 // do not re-create existing attributes 6942 if (!div.hasAttribute(atts[i].nodeName)) { 6943 div.setAttribute(atts[i].nodeName, atts[i].nodeValue); 6944 } 6945 } 6946 6947 // If textarea has a form, listen for reset on the form to clear 6948 // the content of the created div 6949 if (textarea.form) { 6950 this.on(textarea.form, 'reset', function (event) { 6951 if (!event.defaultPrevented) { 6952 this.resetContent(this.options.ownerDocument.getElementById(uniqueId)); 6953 } 6954 }.bind(this)); 6955 } 6956 6957 textarea.classList.add('medium-editor-hidden'); 6958 textarea.parentNode.insertBefore( 6959 div, 6960 textarea 6961 ); 6962 6963 return div; 6964 } 6965 6966 function initElement(element, editorId) { 6967 if (!element.getAttribute('data-medium-editor-element')) { 6968 if (element.nodeName.toLowerCase() === 'textarea') { 6969 element = createContentEditable.call(this, element); 6970 6971 // Make sure we only attach to editableInput once for <textarea> elements 6972 if (!this.instanceHandleEditableInput) { 6973 this.instanceHandleEditableInput = handleEditableInput.bind(this); 6974 this.subscribe('editableInput', this.instanceHandleEditableInput); 6975 } 6976 } 6977 6978 if (!this.options.disableEditing && !element.getAttribute('data-disable-editing')) { 6979 element.setAttribute('contentEditable', true); 6980 element.setAttribute('spellcheck', this.options.spellcheck); 6981 } 6982 6983 // Make sure we only attach to editableKeydownEnter once for disable-return options 6984 if (!this.instanceHandleEditableKeydownEnter) { 6985 if (element.getAttribute('data-disable-return') || element.getAttribute('data-disable-double-return')) { 6986 this.instanceHandleEditableKeydownEnter = handleDisabledEnterKeydown.bind(this); 6987 this.subscribe('editableKeydownEnter', this.instanceHandleEditableKeydownEnter); 6988 } 6989 } 6990 6991 // if we're not disabling return, add a handler to help handle cleanup 6992 // for certain cases when enter is pressed 6993 if (!this.options.disableReturn && !element.getAttribute('data-disable-return')) { 6994 this.on(element, 'keyup', handleKeyup.bind(this)); 6995 } 6996 6997 var elementId = MediumEditor.util.guid(); 6998 6999 element.setAttribute('data-medium-editor-element', true); 7000 element.classList.add('medium-editor-element'); 7001 element.setAttribute('role', 'textbox'); 7002 element.setAttribute('aria-multiline', true); 7003 element.setAttribute('data-medium-editor-editor-index', editorId); 7004 // TODO: Merge data-medium-editor-element and medium-editor-index attributes for 6.0.0 7005 // medium-editor-index is not named correctly anymore and can be re-purposed to signify 7006 // whether the element has been initialized or not 7007 element.setAttribute('medium-editor-index', elementId); 7008 initialContent[elementId] = element.innerHTML; 7009 7010 this.events.attachAllEventsToElement(element); 7011 } 7012 7013 return element; 7014 } 7015 7016 function attachHandlers() { 7017 // attach to tabs 7018 this.subscribe('editableKeydownTab', handleTabKeydown.bind(this)); 7019 7020 // Bind keys which can create or destroy a block element: backspace, delete, return 7021 this.subscribe('editableKeydownDelete', handleBlockDeleteKeydowns.bind(this)); 7022 this.subscribe('editableKeydownEnter', handleBlockDeleteKeydowns.bind(this)); 7023 7024 // Bind double space event 7025 if (this.options.disableExtraSpaces) { 7026 this.subscribe('editableKeydownSpace', handleDisableExtraSpaces.bind(this)); 7027 } 7028 7029 // Make sure we only attach to editableKeydownEnter once for disable-return options 7030 if (!this.instanceHandleEditableKeydownEnter) { 7031 // disabling return or double return 7032 if (this.options.disableReturn || this.options.disableDoubleReturn) { 7033 this.instanceHandleEditableKeydownEnter = handleDisabledEnterKeydown.bind(this); 7034 this.subscribe('editableKeydownEnter', this.instanceHandleEditableKeydownEnter); 7035 } 7036 } 7037 } 7038 7039 function initExtensions() { 7040 7041 this.extensions = []; 7042 7043 // Passed in extensions 7044 Object.keys(this.options.extensions).forEach(function (name) { 7045 // Always save the toolbar extension for last 7046 if (name !== 'toolbar' && this.options.extensions[name]) { 7047 this.extensions.push(initExtension(this.options.extensions[name], name, this)); 7048 } 7049 }, this); 7050 7051 // 4 Cases for imageDragging + fileDragging extensons: 7052 // 7053 // 1. ImageDragging ON + No Custom Image Dragging Extension: 7054 // * Use fileDragging extension (default options) 7055 // 2. ImageDragging OFF + No Custom Image Dragging Extension: 7056 // * Use fileDragging extension w/ images turned off 7057 // 3. ImageDragging ON + Custom Image Dragging Extension: 7058 // * Don't use fileDragging (could interfere with custom image dragging extension) 7059 // 4. ImageDragging OFF + Custom Image Dragging: 7060 // * Don't use fileDragging (could interfere with custom image dragging extension) 7061 if (shouldUseFileDraggingExtension.call(this)) { 7062 var opts = this.options.fileDragging; 7063 if (!opts) { 7064 opts = {}; 7065 7066 // Image is in the 'allowedTypes' list by default. 7067 // If imageDragging is off override the 'allowedTypes' list with an empty one 7068 if (!isImageDraggingEnabled.call(this)) { 7069 opts.allowedTypes = []; 7070 } 7071 } 7072 this.addBuiltInExtension('fileDragging', opts); 7073 } 7074 7075 // Built-in extensions 7076 var builtIns = { 7077 paste: true, 7078 'anchor-preview': isAnchorPreviewEnabled.call(this), 7079 autoLink: isAutoLinkEnabled.call(this), 7080 keyboardCommands: isKeyboardCommandsEnabled.call(this), 7081 placeholder: isPlaceholderEnabled.call(this) 7082 }; 7083 Object.keys(builtIns).forEach(function (name) { 7084 if (builtIns[name]) { 7085 this.addBuiltInExtension(name); 7086 } 7087 }, this); 7088 7089 // Users can pass in a custom toolbar extension 7090 // so check for that first and if it's not present 7091 // just create the default toolbar 7092 var toolbarExtension = this.options.extensions['toolbar']; 7093 if (!toolbarExtension && isToolbarEnabled.call(this)) { 7094 // Backwards compatability 7095 var toolbarOptions = MediumEditor.util.extend({}, this.options.toolbar, { 7096 allowMultiParagraphSelection: this.options.allowMultiParagraphSelection // deprecated 7097 }); 7098 toolbarExtension = new MediumEditor.extensions.toolbar(toolbarOptions); 7099 } 7100 7101 // If the toolbar is not disabled, so we actually have an extension 7102 // initialize it and add it to the extensions array 7103 if (toolbarExtension) { 7104 this.extensions.push(initExtension(toolbarExtension, 'toolbar', this)); 7105 } 7106 } 7107 7108 function mergeOptions(defaults, options) { 7109 var deprecatedProperties = [ 7110 ['allowMultiParagraphSelection', 'toolbar.allowMultiParagraphSelection'] 7111 ]; 7112 // warn about using deprecated properties 7113 if (options) { 7114 deprecatedProperties.forEach(function (pair) { 7115 if (options.hasOwnProperty(pair[0]) && options[pair[0]] !== undefined) { 7116 MediumEditor.util.deprecated(pair[0], pair[1], 'v6.0.0'); 7117 } 7118 }); 7119 } 7120 7121 return MediumEditor.util.defaults({}, options, defaults); 7122 } 7123 7124 function execActionInternal(action, opts) { 7125 /*jslint regexp: true*/ 7126 var appendAction = /^append-(.+)$/gi, 7127 justifyAction = /justify([A-Za-z]*)$/g, /* Detecting if is justifyCenter|Right|Left */ 7128 match, 7129 cmdValueArgument; 7130 /*jslint regexp: false*/ 7131 7132 // Actions starting with 'append-' should attempt to format a block of text ('formatBlock') using a specific 7133 // type of block element (ie append-blockquote, append-h1, append-pre, etc.) 7134 match = appendAction.exec(action); 7135 if (match) { 7136 return MediumEditor.util.execFormatBlock(this.options.ownerDocument, match[1]); 7137 } 7138 7139 if (action === 'fontSize') { 7140 // TODO: Deprecate support for opts.size in 6.0.0 7141 if (opts.size) { 7142 MediumEditor.util.deprecated('.size option for fontSize command', '.value', '6.0.0'); 7143 } 7144 cmdValueArgument = opts.value || opts.size; 7145 return this.options.ownerDocument.execCommand('fontSize', false, cmdValueArgument); 7146 } 7147 7148 if (action === 'fontName') { 7149 // TODO: Deprecate support for opts.name in 6.0.0 7150 if (opts.name) { 7151 MediumEditor.util.deprecated('.name option for fontName command', '.value', '6.0.0'); 7152 } 7153 cmdValueArgument = opts.value || opts.name; 7154 return this.options.ownerDocument.execCommand('fontName', false, cmdValueArgument); 7155 } 7156 7157 if (action === 'createLink') { 7158 return this.createLink(opts); 7159 } 7160 7161 if (action === 'image') { 7162 var src = this.options.contentWindow.getSelection().toString().trim(); 7163 return this.options.ownerDocument.execCommand('insertImage', false, src); 7164 } 7165 7166 if (action === 'html') { 7167 var html = this.options.contentWindow.getSelection().toString().trim(); 7168 return MediumEditor.util.insertHTMLCommand(this.options.ownerDocument, html); 7169 } 7170 7171 /* Issue: https://github.com/yabwe/medium-editor/issues/595 7172 * If the action is to justify the text */ 7173 if (justifyAction.exec(action)) { 7174 var result = this.options.ownerDocument.execCommand(action, false, null), 7175 parentNode = MediumEditor.selection.getSelectedParentElement(MediumEditor.selection.getSelectionRange(this.options.ownerDocument)); 7176 if (parentNode) { 7177 cleanupJustifyDivFragments.call(this, MediumEditor.util.getTopBlockContainer(parentNode)); 7178 } 7179 7180 return result; 7181 } 7182 7183 cmdValueArgument = opts && opts.value; 7184 return this.options.ownerDocument.execCommand(action, false, cmdValueArgument); 7185 } 7186 7187 /* If we've just justified text within a container block 7188 * Chrome may have removed <br> elements and instead wrapped lines in <div> elements 7189 * with a text-align property. If so, we want to fix this 7190 */ 7191 function cleanupJustifyDivFragments(blockContainer) { 7192 if (!blockContainer) { 7193 return; 7194 } 7195 7196 var textAlign, 7197 childDivs = Array.prototype.slice.call(blockContainer.childNodes).filter(function (element) { 7198 var isDiv = element.nodeName.toLowerCase() === 'div'; 7199 if (isDiv && !textAlign) { 7200 textAlign = element.style.textAlign; 7201 } 7202 return isDiv; 7203 }); 7204 7205 /* If we found child <div> elements with text-align style attributes 7206 * we should fix this by: 7207 * 7208 * 1) Unwrapping each <div> which has a text-align style 7209 * 2) Insert a <br> element after each set of 'unwrapped' div children 7210 * 3) Set the text-align style of the parent block element 7211 */ 7212 if (childDivs.length) { 7213 // Since we're mucking with the HTML, preserve selection 7214 this.saveSelection(); 7215 childDivs.forEach(function (div) { 7216 if (div.style.textAlign === textAlign) { 7217 var lastChild = div.lastChild; 7218 if (lastChild) { 7219 // Instead of a div, extract the child elements and add a <br> 7220 MediumEditor.util.unwrap(div, this.options.ownerDocument); 7221 var br = this.options.ownerDocument.createElement('BR'); 7222 lastChild.parentNode.insertBefore(br, lastChild.nextSibling); 7223 } 7224 } 7225 }, this); 7226 blockContainer.style.textAlign = textAlign; 7227 // We're done, so restore selection 7228 this.restoreSelection(); 7229 } 7230 } 7231 7232 var initialContent = {}; 7233 7234 MediumEditor.prototype = { 7235 // NOT DOCUMENTED - exposed for backwards compatability 7236 init: function (elements, options) { 7237 this.options = mergeOptions.call(this, this.defaults, options); 7238 this.origElements = elements; 7239 7240 if (!this.options.elementsContainer) { 7241 this.options.elementsContainer = this.options.ownerDocument.body; 7242 } 7243 7244 return this.setup(); 7245 }, 7246 7247 setup: function () { 7248 if (this.isActive) { 7249 return; 7250 } 7251 7252 addToEditors.call(this, this.options.contentWindow); 7253 this.events = new MediumEditor.Events(this); 7254 this.elements = []; 7255 7256 // Call initialization helpers 7257 initExtensions.call(this); 7258 attachHandlers.call(this); 7259 7260 this.addElements(this.origElements); 7261 7262 this.isActive = true; 7263 }, 7264 7265 destroy: function () { 7266 if (!this.isActive) { 7267 return; 7268 } 7269 7270 this.isActive = false; 7271 7272 this.extensions.forEach(function (extension) { 7273 if (typeof extension.destroy === 'function') { 7274 extension.destroy(); 7275 } 7276 }, this); 7277 7278 this.events.destroy(); 7279 7280 this.elements.forEach(function (element, i) { 7281 // Call the getContent to set innerHTML, extensions can do some cleanup 7282 element.innerHTML = this.getContent(i); 7283 7284 // cleanup extra added attributes 7285 element.removeAttribute('contentEditable'); 7286 element.removeAttribute('spellcheck'); 7287 element.removeAttribute('data-medium-editor-element'); 7288 element.classList.remove('medium-editor-element'); 7289 element.removeAttribute('role'); 7290 element.removeAttribute('aria-multiline'); 7291 element.removeAttribute('medium-editor-index'); 7292 element.removeAttribute('data-medium-editor-editor-index'); 7293 7294 // Remove any elements created for textareas 7295 if (element.getAttribute('medium-editor-textarea-id')) { 7296 cleanupTextareaElement(element); 7297 } 7298 }, this); 7299 this.elements = []; 7300 this.instanceHandleEditableKeydownEnter = null; 7301 this.instanceHandleEditableInput = null; 7302 7303 removeFromEditors.call(this, this.options.contentWindow); 7304 }, 7305 7306 on: function (target, event, listener, useCapture) { 7307 this.events.attachDOMEvent(target, event, listener, useCapture); 7308 7309 return this; 7310 }, 7311 7312 off: function (target, event, listener, useCapture) { 7313 this.events.detachDOMEvent(target, event, listener, useCapture); 7314 7315 return this; 7316 }, 7317 7318 subscribe: function (event, listener) { 7319 this.events.attachCustomEvent(event, listener); 7320 7321 return this; 7322 }, 7323 7324 unsubscribe: function (event, listener) { 7325 this.events.detachCustomEvent(event, listener); 7326 7327 return this; 7328 }, 7329 7330 trigger: function (name, data, editable) { 7331 this.events.triggerCustomEvent(name, data, editable); 7332 7333 return this; 7334 }, 7335 7336 delay: function (fn) { 7337 var self = this; 7338 return setTimeout(function () { 7339 if (self.isActive) { 7340 fn(); 7341 } 7342 }, this.options.delay); 7343 }, 7344 7345 serialize: function () { 7346 var i, 7347 elementid, 7348 content = {}, 7349 len = this.elements.length; 7350 7351 for (i = 0; i < len; i += 1) { 7352 elementid = (this.elements[i].id !== '') ? this.elements[i].id : 'element-' + i; 7353 content[elementid] = { 7354 value: this.getContent(i) 7355 }; 7356 } 7357 return content; 7358 }, 7359 7360 getExtensionByName: function (name) { 7361 var extension; 7362 if (this.extensions && this.extensions.length) { 7363 this.extensions.some(function (ext) { 7364 if (ext.name === name) { 7365 extension = ext; 7366 return true; 7367 } 7368 return false; 7369 }); 7370 } 7371 return extension; 7372 }, 7373 7374 /** 7375 * NOT DOCUMENTED - exposed as a helper for other extensions to use 7376 */ 7377 addBuiltInExtension: function (name, opts) { 7378 var extension = this.getExtensionByName(name), 7379 merged; 7380 if (extension) { 7381 return extension; 7382 } 7383 7384 switch (name) { 7385 case 'anchor': 7386 merged = MediumEditor.util.extend({}, this.options.anchor, opts); 7387 extension = new MediumEditor.extensions.anchor(merged); 7388 break; 7389 case 'anchor-preview': 7390 extension = new MediumEditor.extensions.anchorPreview(this.options.anchorPreview); 7391 break; 7392 case 'autoLink': 7393 extension = new MediumEditor.extensions.autoLink(); 7394 break; 7395 case 'fileDragging': 7396 extension = new MediumEditor.extensions.fileDragging(opts); 7397 break; 7398 case 'fontname': 7399 extension = new MediumEditor.extensions.fontName(this.options.fontName); 7400 break; 7401 case 'fontsize': 7402 extension = new MediumEditor.extensions.fontSize(opts); 7403 break; 7404 case 'keyboardCommands': 7405 extension = new MediumEditor.extensions.keyboardCommands(this.options.keyboardCommands); 7406 break; 7407 case 'paste': 7408 extension = new MediumEditor.extensions.paste(this.options.paste); 7409 break; 7410 case 'placeholder': 7411 extension = new MediumEditor.extensions.placeholder(this.options.placeholder); 7412 break; 7413 default: 7414 // All of the built-in buttons for MediumEditor are extensions 7415 // so check to see if the extension we're creating is a built-in button 7416 if (MediumEditor.extensions.button.isBuiltInButton(name)) { 7417 if (opts) { 7418 merged = MediumEditor.util.defaults({}, opts, MediumEditor.extensions.button.prototype.defaults[name]); 7419 extension = new MediumEditor.extensions.button(merged); 7420 } else { 7421 extension = new MediumEditor.extensions.button(name); 7422 } 7423 } 7424 } 7425 7426 if (extension) { 7427 this.extensions.push(initExtension(extension, name, this)); 7428 } 7429 7430 return extension; 7431 }, 7432 7433 stopSelectionUpdates: function () { 7434 this.preventSelectionUpdates = true; 7435 }, 7436 7437 startSelectionUpdates: function () { 7438 this.preventSelectionUpdates = false; 7439 }, 7440 7441 checkSelection: function () { 7442 var toolbar = this.getExtensionByName('toolbar'); 7443 if (toolbar) { 7444 toolbar.checkState(); 7445 } 7446 return this; 7447 }, 7448 7449 // Wrapper around document.queryCommandState for checking whether an action has already 7450 // been applied to the current selection 7451 queryCommandState: function (action) { 7452 var fullAction = /^full-(.+)$/gi, 7453 match, 7454 queryState = null; 7455 7456 // Actions starting with 'full-' need to be modified since this is a medium-editor concept 7457 match = fullAction.exec(action); 7458 if (match) { 7459 action = match[1]; 7460 } 7461 7462 try { 7463 queryState = this.options.ownerDocument.queryCommandState(action); 7464 } catch (exc) { 7465 queryState = null; 7466 } 7467 7468 return queryState; 7469 }, 7470 7471 execAction: function (action, opts) { 7472 /*jslint regexp: true*/ 7473 var fullAction = /^full-(.+)$/gi, 7474 match, 7475 result; 7476 /*jslint regexp: false*/ 7477 7478 // Actions starting with 'full-' should be applied to to the entire contents of the editable element 7479 // (ie full-bold, full-append-pre, etc.) 7480 match = fullAction.exec(action); 7481 if (match) { 7482 // Store the current selection to be restored after applying the action 7483 this.saveSelection(); 7484 // Select all of the contents before calling the action 7485 this.selectAllContents(); 7486 result = execActionInternal.call(this, match[1], opts); 7487 // Restore the previous selection 7488 this.restoreSelection(); 7489 } else { 7490 result = execActionInternal.call(this, action, opts); 7491 } 7492 7493 // do some DOM clean-up for known browser issues after the action 7494 if (action === 'insertunorderedlist' || action === 'insertorderedlist') { 7495 MediumEditor.util.cleanListDOM(this.options.ownerDocument, this.getSelectedParentElement()); 7496 } 7497 7498 this.checkSelection(); 7499 return result; 7500 }, 7501 7502 getSelectedParentElement: function (range) { 7503 if (range === undefined) { 7504 range = this.options.contentWindow.getSelection().getRangeAt(0); 7505 } 7506 return MediumEditor.selection.getSelectedParentElement(range); 7507 }, 7508 7509 selectAllContents: function () { 7510 var currNode = MediumEditor.selection.getSelectionElement(this.options.contentWindow); 7511 7512 if (currNode) { 7513 // Move to the lowest descendant node that still selects all of the contents 7514 while (currNode.children.length === 1) { 7515 currNode = currNode.children[0]; 7516 } 7517 7518 this.selectElement(currNode); 7519 } 7520 }, 7521 7522 selectElement: function (element) { 7523 MediumEditor.selection.selectNode(element, this.options.ownerDocument); 7524 7525 var selElement = MediumEditor.selection.getSelectionElement(this.options.contentWindow); 7526 if (selElement) { 7527 this.events.focusElement(selElement); 7528 } 7529 }, 7530 7531 getFocusedElement: function () { 7532 var focused; 7533 this.elements.some(function (element) { 7534 // Find the element that has focus 7535 if (!focused && element.getAttribute('data-medium-focused')) { 7536 focused = element; 7537 } 7538 7539 // bail if we found the element that had focus 7540 return !!focused; 7541 }, this); 7542 7543 return focused; 7544 }, 7545 7546 // Export the state of the selection in respect to one of this 7547 // instance of MediumEditor's elements 7548 exportSelection: function () { 7549 var selectionElement = MediumEditor.selection.getSelectionElement(this.options.contentWindow), 7550 editableElementIndex = this.elements.indexOf(selectionElement), 7551 selectionState = null; 7552 7553 if (editableElementIndex >= 0) { 7554 selectionState = MediumEditor.selection.exportSelection(selectionElement, this.options.ownerDocument); 7555 } 7556 7557 if (selectionState !== null && editableElementIndex !== 0) { 7558 selectionState.editableElementIndex = editableElementIndex; 7559 } 7560 7561 return selectionState; 7562 }, 7563 7564 saveSelection: function () { 7565 this.selectionState = this.exportSelection(); 7566 }, 7567 7568 // Restore a selection based on a selectionState returned by a call 7569 // to MediumEditor.exportSelection 7570 importSelection: function (selectionState, favorLaterSelectionAnchor) { 7571 if (!selectionState) { 7572 return; 7573 } 7574 7575 var editableElement = this.elements[selectionState.editableElementIndex || 0]; 7576 MediumEditor.selection.importSelection(selectionState, editableElement, this.options.ownerDocument, favorLaterSelectionAnchor); 7577 }, 7578 7579 restoreSelection: function () { 7580 this.importSelection(this.selectionState); 7581 }, 7582 7583 createLink: function (opts) { 7584 var currentEditor = MediumEditor.selection.getSelectionElement(this.options.contentWindow), 7585 customEvent = {}, 7586 targetUrl; 7587 7588 // Make sure the selection is within an element this editor is tracking 7589 if (this.elements.indexOf(currentEditor) === -1) { 7590 return; 7591 } 7592 7593 try { 7594 this.events.disableCustomEvent('editableInput'); 7595 // TODO: Deprecate support for opts.url in 6.0.0 7596 if (opts.url) { 7597 MediumEditor.util.deprecated('.url option for createLink', '.value', '6.0.0'); 7598 } 7599 targetUrl = opts.url || opts.value; 7600 if (targetUrl && targetUrl.trim().length > 0) { 7601 var currentSelection = this.options.contentWindow.getSelection(); 7602 if (currentSelection) { 7603 var currRange = currentSelection.getRangeAt(0), 7604 commonAncestorContainer = currRange.commonAncestorContainer, 7605 exportedSelection, 7606 startContainerParentElement, 7607 endContainerParentElement, 7608 textNodes; 7609 7610 // If the selection is contained within a single text node 7611 // and the selection starts at the beginning of the text node, 7612 // MSIE still says the startContainer is the parent of the text node. 7613 // If the selection is contained within a single text node, we 7614 // want to just use the default browser 'createLink', so we need 7615 // to account for this case and adjust the commonAncestorContainer accordingly 7616 if (currRange.endContainer.nodeType === 3 && 7617 currRange.startContainer.nodeType !== 3 && 7618 currRange.startOffset === 0 && 7619 currRange.startContainer.firstChild === currRange.endContainer) { 7620 commonAncestorContainer = currRange.endContainer; 7621 } 7622 7623 startContainerParentElement = MediumEditor.util.getClosestBlockContainer(currRange.startContainer); 7624 endContainerParentElement = MediumEditor.util.getClosestBlockContainer(currRange.endContainer); 7625 7626 // If the selection is not contained within a single text node 7627 // but the selection is contained within the same block element 7628 // we want to make sure we create a single link, and not multiple links 7629 // which can happen with the built in browser functionality 7630 if (commonAncestorContainer.nodeType !== 3 && commonAncestorContainer.textContent.length !== 0 && startContainerParentElement === endContainerParentElement) { 7631 var parentElement = (startContainerParentElement || currentEditor), 7632 fragment = this.options.ownerDocument.createDocumentFragment(); 7633 7634 // since we are going to create a link from an extracted text, 7635 // be sure that if we are updating a link, we won't let an empty link behind (see #754) 7636 // (Workaroung for Chrome) 7637 this.execAction('unlink'); 7638 7639 exportedSelection = this.exportSelection(); 7640 fragment.appendChild(parentElement.cloneNode(true)); 7641 7642 if (currentEditor === parentElement) { 7643 // We have to avoid the editor itself being wiped out when it's the only block element, 7644 // as our reference inside this.elements gets detached from the page when insertHTML runs. 7645 // If we just use [parentElement, 0] and [parentElement, parentElement.childNodes.length] 7646 // as the range boundaries, this happens whenever parentElement === currentEditor. 7647 // The tradeoff to this workaround is that a orphaned tag can sometimes be left behind at 7648 // the end of the editor's content. 7649 // In Gecko: 7650 // as an empty <strong></strong> if parentElement.lastChild is a <strong> tag. 7651 // In WebKit: 7652 // an invented <br /> tag at the end in the same situation 7653 MediumEditor.selection.select( 7654 this.options.ownerDocument, 7655 parentElement.firstChild, 7656 0, 7657 parentElement.lastChild, 7658 parentElement.lastChild.nodeType === 3 ? 7659 parentElement.lastChild.nodeValue.length : parentElement.lastChild.childNodes.length 7660 ); 7661 } else { 7662 MediumEditor.selection.select( 7663 this.options.ownerDocument, 7664 parentElement, 7665 0, 7666 parentElement, 7667 parentElement.childNodes.length 7668 ); 7669 } 7670 7671 var modifiedExportedSelection = this.exportSelection(); 7672 7673 textNodes = MediumEditor.util.findOrCreateMatchingTextNodes( 7674 this.options.ownerDocument, 7675 fragment, 7676 {
7677 start: exportedSelection.start - modifiedExportedSelection.start, 7678 end: exportedSelection.end - modifiedExportedSelection.start, 7679 editableElementIndex: exportedSelection.editableElementIndex 7680 } 7681 ); 7682 // If textNodes are not present, when changing link on images 7683 // ex: <a><img src="http://image.test.com"></a>, change fragment to currRange.startContainer 7684 // and set textNodes array to [imageElement, imageElement] 7685 if (textNodes.length === 0) { 7686 fragment = this.options.ownerDocument.createDocumentFragment(); 7687 fragment.appendChild(commonAncestorContainer.cloneNode(true)); 7688 textNodes = [fragment.firstChild.firstChild, fragment.firstChild.lastChild]; 7689 } 7690 7691 // Creates the link in the document fragment 7692 MediumEditor.util.createLink(this.options.ownerDocument, textNodes, targetUrl.trim()); 7693 7694 // Chrome trims the leading whitespaces when inserting HTML, which messes up restoring the selection. 7695 var leadingWhitespacesCount = (fragment.firstChild.innerHTML.match(/^\s+/) || [''])[0].length; 7696 7697 // Now move the created link back into the original document in a way to preserve undo/redo history 7698 MediumEditor.util.insertHTMLCommand(this.options.ownerDocument, fragment.firstChild.innerHTML.replace(/^\s+/, '')); 7699 exportedSelection.start -= leadingWhitespacesCount; 7700 exportedSelection.end -= leadingWhitespacesCount; 7701 7702 this.importSelection(exportedSelection); 7703 } else { 7704 this.options.ownerDocument.execCommand('createLink', false, targetUrl); 7705 } 7706 7707 if (this.options.targetBlank || opts.target === '_blank') { 7708 MediumEditor.util.setTargetBlank(MediumEditor.selection.getSelectionStart(this.options.ownerDocument), targetUrl); 7709 } else { 7710 MediumEditor.util.removeTargetBlank(MediumEditor.selection.getSelectionStart(this.options.ownerDocument), targetUrl); 7711 } 7712 7713 if (opts.buttonClass) { 7714 MediumEditor.util.addClassToAnchors(MediumEditor.selection.getSelectionStart(this.options.ownerDocument), opts.buttonClass); 7715 } 7716 } 7717 } 7718 // Fire input event for backwards compatibility if anyone was listening directly to the DOM input event 7719 if (this.options.targetBlank || opts.target === '_blank' || opts.buttonClass) { 7720 customEvent = this.options.ownerDocument.createEvent('HTMLEvents'); 7721 customEvent.initEvent('input', true, true, this.options.contentWindow); 7722 for (var i = 0, len = this.elements.length; i < len; i += 1) { 7723 this.elements[i].dispatchEvent(customEvent); 7724 } 7725 } 7726 } finally { 7727 this.events.enableCustomEvent('editableInput'); 7728 } 7729 // Fire our custom editableInput event 7730 this.events.triggerCustomEvent('editableInput', customEvent, currentEditor); 7731 }, 7732 7733 cleanPaste: function (text) { 7734 this.getExtensionByName('paste').cleanPaste(text); 7735 }, 7736 7737 pasteHTML: function (html, options) { 7738 this.getExtensionByName('paste').pasteHTML(html, options); 7739 }, 7740 7741 setContent: function (html, index) { 7742 index = index || 0; 7743 7744 if (this.elements[index]) { 7745 var target = this.elements[index]; 7746 7747 this.extensions.forEach(function (extension) { 7748 if (typeof extension.setContent === 'function') { 7749 html = extension.setContent(html); 7750 } 7751 }, this); 7752 7753 target.innerHTML = html; 7754 7755 this.checkContentChanged(target); 7756 } 7757 }, 7758 7759 getContent: function (index) { 7760 index = index || 0; 7761 7762 if (this.elements[index]) { 7763 var html = this.elements[index].innerHTML.trim(); 7764 7765 this.extensions.forEach(function (extension) { 7766 if (typeof extension.getContent === 'function') { 7767 html = extension.getContent(html); 7768 } 7769 }, this); 7770 7771 return html; 7772 } 7773 7774 return null; 7775 }, 7776 7777 checkContentChanged: function (editable) { 7778 editable = editable || MediumEditor.selection.getSelectionElement(this.options.contentWindow); 7779 this.events.updateInput(editable, { target: editable, currentTarget: editable }); 7780 }, 7781 7782 resetContent: function (element) { 7783 // For all elements that exist in the this.elements array, we can assume: 7784 // - Its initial content has been set in the initialContent object 7785 // - It has a medium-editor-index attribute which is the key value in the initialContent object 7786 7787 if (element) { 7788 var index = this.elements.indexOf(element); 7789 if (index !== -1) { 7790 this.setContent(initialContent[element.getAttribute('medium-editor-index')], index); 7791 } 7792 return; 7793 } 7794
vendor: 4,141 bytes, lines 7795-7895
7795 this.elements.forEach(function (el, idx) { 7796 this.setContent(initialContent[el.getAttribute('medium-editor-index')], idx); 7797 }, this); 7798 }, 7799 7800 addElements: function (selector) { 7801 // Convert elements into an array 7802 var elements = createElementsArray(selector, this.options.ownerDocument, true); 7803 7804 // Do we have elements to add now? 7805 if (elements.length === 0) { 7806 return false; 7807 } 7808 7809 elements.forEach(function (element) { 7810 // Initialize all new elements (we check that in those functions don't worry) 7811 element = initElement.call(this, element, this.id); 7812 7813 // Add new elements to our internal elements array 7814 this.elements.push(element); 7815 7816 this.setContent(element.innerHTML, this.elements.length - 1); 7817 7818 // Trigger event so extensions can know when an element has been added 7819 this.trigger('addElement', { target: element, currentTarget: element }, element); 7820 }, this); 7821 }, 7822 7823 removeElements: function (selector) { 7824 // Convert elements into an array 7825 var elements = createElementsArray(selector, this.options.ownerDocument), 7826 toRemove = elements.map(function (el) { 7827 // For textareas, make sure we're looking at the editor div and not the textarea itself 7828 if (el.getAttribute('medium-editor-textarea-id') && el.parentNode) { 7829 return el.parentNode.querySelector('div[medium-editor-textarea-id="' + el.getAttribute('medium-editor-textarea-id') + '"]'); 7830 } else { 7831 return el; 7832 } 7833 }); 7834 7835 this.elements = this.elements.filter(function (element) { 7836 // If this is an element we want to remove 7837 if (toRemove.indexOf(element) !== -1) { 7838 this.events.cleanupElement(element); 7839 if (element.getAttribute('medium-editor-textarea-id')) { 7840 cleanupTextareaElement(element); 7841 } 7842 // Trigger event so extensions can clean-up elements that are being removed 7843 this.trigger('removeElement', { target: element, currentTarget: element }, element); 7844 return false; 7845 } 7846 return true; 7847 }, this); 7848 } 7849 }; 7850 7851 MediumEditor.getEditorFromElement = function (element) { 7852 var index = element.getAttribute('data-medium-editor-editor-index'), 7853 win = element && element.ownerDocument && (element.ownerDocument.defaultView || element.ownerDocument.parentWindow); 7854 if (win && win._mediumEditors && win._mediumEditors[index]) { 7855 return win._mediumEditors[index]; 7856 } 7857 return null; 7858 }; 7859}()); 7860 7861(function () { 7862 // summary: The default options hash used by the Editor 7863 7864 MediumEditor.prototype.defaults = { 7865 activeButtonClass: 'medium-editor-button-active', 7866 buttonLabels: false, 7867 delay: 0, 7868 disableReturn: false, 7869 disableDoubleReturn: false, 7870 disableExtraSpaces: false, 7871 disableEditing: false, 7872 autoLink: false, 7873 elementsContainer: false, 7874 contentWindow: window, 7875 ownerDocument: document, 7876 targetBlank: false, 7877 extensions: {}, 7878 spellcheck: true 7879 }; 7880})(); 7881 7882MediumEditor.parseVersionString = function (release) { 7883 var split = release.split('-'), 7884 version = split[0].split('.'), 7885 preRelease = (split.length > 1) ? split[1] : ''; 7886 return { 7887 major: parseInt(version[0], 10), 7888 minor: parseInt(version[1], 10), 7889 revision: parseInt(version[2], 10), 7890 preRelease: preRelease, 7891 toString: function () { 7892 return [version[0], version[1], version[2]].join('.') + (preRelease ? '-' + preRelease : ''); 7893 } 7894 }; 7895};
7896 7897MediumEditor.version = MediumEditor.parseVersionString.call(this, ({ 7898 // grunt-bump looks for this: 7899 'version': '5.23.1' 7900}).version); 7901 7902 return MediumEditor; 7903}()));
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.