UNPKG

medium-editor

Version:
1,149 lines (972 loc) 56.2 kB
(function () { 'use strict'; // Event handlers that shouldn't be exposed externally function handleDisableExtraSpaces(event) { var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument), textContent = node.textContent, caretPositions = MediumEditor.selection.getCaretOffsets(node); if ((textContent[caretPositions.left - 1] === undefined) || (textContent[caretPositions.left - 1].trim() === '') || (textContent[caretPositions.left] !== undefined && textContent[caretPositions.left].trim() === '')) { event.preventDefault(); } } function handleDisabledEnterKeydown(event, element) { if (this.options.disableReturn || element.getAttribute('data-disable-return')) { event.preventDefault(); } else if (this.options.disableDoubleReturn || element.getAttribute('data-disable-double-return')) { var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument); // if current text selection is empty OR previous sibling text is empty OR it is not a list if ((node && node.textContent.trim() === '' && node.nodeName.toLowerCase() !== 'li') || (node.previousElementSibling && node.previousElementSibling.nodeName.toLowerCase() !== 'br' && node.previousElementSibling.textContent.trim() === '')) { event.preventDefault(); } } } function handleTabKeydown(event) { // Override tab only for pre nodes var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument), tag = node && node.nodeName.toLowerCase(); if (tag === 'pre') { event.preventDefault(); MediumEditor.util.insertHTMLCommand(this.options.ownerDocument, ' '); } // Tab to indent list structures! if (MediumEditor.util.isListItem(node)) { event.preventDefault(); // If Shift is down, outdent, otherwise indent if (event.shiftKey) { this.options.ownerDocument.execCommand('outdent', false, null); } else { this.options.ownerDocument.execCommand('indent', false, null); } } } function handleBlockDeleteKeydowns(event) { var p, node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument), tagName = node.nodeName.toLowerCase(), isEmpty = /^(\s+|<br\/?>)?$/i, isHeader = /h\d/i; if (MediumEditor.util.isKey(event, [MediumEditor.util.keyCode.BACKSPACE, MediumEditor.util.keyCode.ENTER]) && // has a preceeding sibling node.previousElementSibling && // in a header isHeader.test(tagName) && // at the very end of the block MediumEditor.selection.getCaretOffsets(node).left === 0) { if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) && isEmpty.test(node.previousElementSibling.innerHTML)) { // backspacing the begining of a header into an empty previous element will // change the tagName of the current node to prevent one // instead delete previous node and cancel the event. node.previousElementSibling.parentNode.removeChild(node.previousElementSibling); event.preventDefault(); } else if (!this.options.disableDoubleReturn && MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER)) { // hitting return in the begining of a header will create empty header elements before the current one // instead, make "<p><br></p>" element, which are what happens if you hit return in an empty paragraph p = this.options.ownerDocument.createElement('p'); p.innerHTML = '<br>'; node.previousElementSibling.parentNode.insertBefore(p, node); event.preventDefault(); } } else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.DELETE) && // between two sibling elements node.nextElementSibling && node.previousElementSibling && // not in a header !isHeader.test(tagName) && // in an empty tag isEmpty.test(node.innerHTML) && // when the next tag *is* a header isHeader.test(node.nextElementSibling.nodeName.toLowerCase())) { // hitting delete in an empty element preceding a header, ex: // <p>[CURSOR]</p><h1>Header</h1> // Will cause the h1 to become a paragraph. // Instead, delete the paragraph node and move the cursor to the begining of the h1 // remove node and move cursor to start of header MediumEditor.selection.moveCursor(this.options.ownerDocument, node.nextElementSibling); node.previousElementSibling.parentNode.removeChild(node); event.preventDefault(); } else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) && tagName === 'li' && // hitting backspace inside an empty li isEmpty.test(node.innerHTML) && // is first element (no preceeding siblings) !node.previousElementSibling && // parent also does not have a sibling !node.parentElement.previousElementSibling && // is not the only li in a list node.nextElementSibling && node.nextElementSibling.nodeName.toLowerCase() === 'li') { // backspacing in an empty first list element in the first list (with more elements) ex: // <ul><li>[CURSOR]</li><li>List Item 2</li></ul> // will remove the first <li> but add some extra element before (varies based on browser) // Instead, this will: // 1) remove the list element // 2) create a paragraph before the list // 3) move the cursor into the paragraph // create a paragraph before the list p = this.options.ownerDocument.createElement('p'); p.innerHTML = '<br>'; node.parentElement.parentElement.insertBefore(p, node.parentElement); // move the cursor into the new paragraph MediumEditor.selection.moveCursor(this.options.ownerDocument, p); // remove the list element node.parentElement.removeChild(node); event.preventDefault(); } else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) && (MediumEditor.util.getClosestTag(node, 'blockquote') !== false) && MediumEditor.selection.getCaretOffsets(node).left === 0) { // when cursor is at the begining of the element and the element is <blockquote> // then pressing backspace key should change the <blockquote> to a <p> tag event.preventDefault(); MediumEditor.util.execFormatBlock(this.options.ownerDocument, 'p'); } else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) && (MediumEditor.util.getClosestTag(node, 'blockquote') !== false) && MediumEditor.selection.getCaretOffsets(node).right === 0) { // when cursor is at the end of <blockquote>, // then pressing enter key should create <p> tag, not <blockquote> p = this.options.ownerDocument.createElement('p'); p.innerHTML = '<br>'; node.parentElement.insertBefore(p, node.nextSibling); // move the cursor into the new paragraph MediumEditor.selection.moveCursor(this.options.ownerDocument, p); event.preventDefault(); } else if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.BACKSPACE) && MediumEditor.util.isMediumEditorElement(node.parentElement) && !node.previousElementSibling && node.nextElementSibling && isEmpty.test(node.innerHTML)) { // when cursor is in the first element, it's empty and user presses backspace, // do delete action instead to get rid of the first element and move caret to 2nd event.preventDefault(); MediumEditor.selection.moveCursor(this.options.ownerDocument, node.nextSibling); node.parentElement.removeChild(node); } } function handleKeyup(event) { var node = MediumEditor.selection.getSelectionStart(this.options.ownerDocument), tagName; if (!node) { return; } // https://github.com/yabwe/medium-editor/issues/994 // Firefox thrown an error when calling `formatBlock` on an empty editable blockContainer that's not a <div> if (MediumEditor.util.isMediumEditorElement(node) && node.children.length === 0 && !MediumEditor.util.isBlockContainer(node)) { this.options.ownerDocument.execCommand('formatBlock', false, 'p'); } // https://github.com/yabwe/medium-editor/issues/834 // https://github.com/yabwe/medium-editor/pull/382 // Don't call format block if this is a block element (ie h1, figCaption, etc.) if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) && !MediumEditor.util.isListItem(node) && !MediumEditor.util.isBlockContainer(node)) { tagName = node.nodeName.toLowerCase(); // For anchor tags, unlink if (tagName === 'a') { this.options.ownerDocument.execCommand('unlink', false, null); } else if (!event.shiftKey && !event.ctrlKey) { this.options.ownerDocument.execCommand('formatBlock', false, 'p'); } } } function handleEditableInput(event, editable) { var textarea = editable.parentNode.querySelector('textarea[medium-editor-textarea-id="' + editable.getAttribute('medium-editor-textarea-id') + '"]'); if (textarea) { textarea.value = editable.innerHTML.trim(); } } // Internal helper methods which shouldn't be exposed externally function addToEditors(win) { if (!win._mediumEditors) { // To avoid breaking users who are assuming that the unique id on // medium-editor elements will start at 1, inserting a 'null' in the // array so the unique-id can always map to the index of the editor instance win._mediumEditors = [null]; } // If this already has a unique id, re-use it if (!this.id) { this.id = win._mediumEditors.length; } win._mediumEditors[this.id] = this; } function removeFromEditors(win) { if (!win._mediumEditors || !win._mediumEditors[this.id]) { return; } /* Setting the instance to null in the array instead of deleting it allows: * 1) Each instance to preserve its own unique-id, even after being destroyed * and initialized again * 2) The unique-id to always correspond to an index in the array of medium-editor * instances. Thus, we will be able to look at a contenteditable, and determine * which instance it belongs to, by indexing into the global array. */ win._mediumEditors[this.id] = null; } function createElementsArray(selector, doc, filterEditorElements) { var elements = []; if (!selector) { selector = []; } // If string, use as query selector if (typeof selector === 'string') { selector = doc.querySelectorAll(selector); } // If element, put into array if (MediumEditor.util.isElement(selector)) { selector = [selector]; } if (filterEditorElements) { // Remove elements that have already been initialized by the editor // selecotr might not be an array (ie NodeList) so use for loop for (var i = 0; i < selector.length; i++) { var el = selector[i]; if (MediumEditor.util.isElement(el) && !el.getAttribute('data-medium-editor-element') && !el.getAttribute('medium-editor-textarea-id')) { elements.push(el); } } } else { // Convert NodeList (or other array like object) into an array elements = Array.prototype.slice.apply(selector); } return elements; } function cleanupTextareaElement(element) { var textarea = element.parentNode.querySelector('textarea[medium-editor-textarea-id="' + element.getAttribute('medium-editor-textarea-id') + '"]'); if (textarea) { // Un-hide the textarea textarea.classList.remove('medium-editor-hidden'); textarea.removeAttribute('medium-editor-textarea-id'); } if (element.parentNode) { element.parentNode.removeChild(element); } } function setExtensionDefaults(extension, defaults) { Object.keys(defaults).forEach(function (prop) { if (extension[prop] === undefined) { extension[prop] = defaults[prop]; } }); return extension; } function initExtension(extension, name, instance) { var extensionDefaults = { 'window': instance.options.contentWindow, 'document': instance.options.ownerDocument, 'base': instance }; // Add default options into the extension extension = setExtensionDefaults(extension, extensionDefaults); // Call init on the extension if (typeof extension.init === 'function') { extension.init(); } // Set extension name (if not already set) if (!extension.name) { extension.name = name; } return extension; } function isToolbarEnabled() { // If any of the elements don't have the toolbar disabled // We need a toolbar if (this.elements.every(function (element) { return !!element.getAttribute('data-disable-toolbar'); })) { return false; } return this.options.toolbar !== false; } function isAnchorPreviewEnabled() { // If toolbar is disabled, don't add if (!isToolbarEnabled.call(this)) { return false; } return this.options.anchorPreview !== false; } function isPlaceholderEnabled() { return this.options.placeholder !== false; } function isAutoLinkEnabled() { return this.options.autoLink !== false; } function isImageDraggingEnabled() { return this.options.imageDragging !== false; } function isKeyboardCommandsEnabled() { return this.options.keyboardCommands !== false; } function shouldUseFileDraggingExtension() { // Since the file-dragging extension replaces the image-dragging extension, // we need to check if the user passed an overrided image-dragging extension. // If they have, to avoid breaking users, we won't use file-dragging extension. return !this.options.extensions['imageDragging']; } function createContentEditable(textarea) { var div = this.options.ownerDocument.createElement('div'), now = Date.now(), uniqueId = 'medium-editor-' + now, atts = textarea.attributes; // Some browsers can move pretty fast, since we're using a timestamp // to make a unique-id, ensure that the id is actually unique on the page while (this.options.ownerDocument.getElementById(uniqueId)) { now++; uniqueId = 'medium-editor-' + now; } div.className = textarea.className; div.id = uniqueId; div.innerHTML = textarea.value; textarea.setAttribute('medium-editor-textarea-id', uniqueId); // re-create all attributes from the textearea to the new created div for (var i = 0, n = atts.length; i < n; i++) { // do not re-create existing attributes if (!div.hasAttribute(atts[i].nodeName)) { div.setAttribute(atts[i].nodeName, atts[i].value); } } // If textarea has a form, listen for reset on the form to clear // the content of the created div if (textarea.form) { this.on(textarea.form, 'reset', function (event) { if (!event.defaultPrevented) { this.resetContent(this.options.ownerDocument.getElementById(uniqueId)); } }.bind(this)); } textarea.classList.add('medium-editor-hidden'); textarea.parentNode.insertBefore( div, textarea ); return div; } function initElement(element, editorId) { if (!element.getAttribute('data-medium-editor-element')) { if (element.nodeName.toLowerCase() === 'textarea') { element = createContentEditable.call(this, element); // Make sure we only attach to editableInput once for <textarea> elements if (!this.instanceHandleEditableInput) { this.instanceHandleEditableInput = handleEditableInput.bind(this); this.subscribe('editableInput', this.instanceHandleEditableInput); } } if (!this.options.disableEditing && !element.getAttribute('data-disable-editing')) { element.setAttribute('contentEditable', true); element.setAttribute('spellcheck', this.options.spellcheck); } // Make sure we only attach to editableKeydownEnter once for disable-return options if (!this.instanceHandleEditableKeydownEnter) { if (element.getAttribute('data-disable-return') || element.getAttribute('data-disable-double-return')) { this.instanceHandleEditableKeydownEnter = handleDisabledEnterKeydown.bind(this); this.subscribe('editableKeydownEnter', this.instanceHandleEditableKeydownEnter); } } // if we're not disabling return, add a handler to help handle cleanup // for certain cases when enter is pressed if (!this.options.disableReturn && !element.getAttribute('data-disable-return')) { this.on(element, 'keyup', handleKeyup.bind(this)); } var elementId = MediumEditor.util.guid(); element.setAttribute('data-medium-editor-element', true); element.classList.add('medium-editor-element'); element.setAttribute('role', 'textbox'); element.setAttribute('aria-multiline', true); element.setAttribute('data-medium-editor-editor-index', editorId); // TODO: Merge data-medium-editor-element and medium-editor-index attributes for 6.0.0 // medium-editor-index is not named correctly anymore and can be re-purposed to signify // whether the element has been initialized or not element.setAttribute('medium-editor-index', elementId); initialContent[elementId] = element.innerHTML; this.events.attachAllEventsToElement(element); } return element; } function attachHandlers() { // attach to tabs this.subscribe('editableKeydownTab', handleTabKeydown.bind(this)); // Bind keys which can create or destroy a block element: backspace, delete, return this.subscribe('editableKeydownDelete', handleBlockDeleteKeydowns.bind(this)); this.subscribe('editableKeydownEnter', handleBlockDeleteKeydowns.bind(this)); // Bind double space event if (this.options.disableExtraSpaces) { this.subscribe('editableKeydownSpace', handleDisableExtraSpaces.bind(this)); } // Make sure we only attach to editableKeydownEnter once for disable-return options if (!this.instanceHandleEditableKeydownEnter) { // disabling return or double return if (this.options.disableReturn || this.options.disableDoubleReturn) { this.instanceHandleEditableKeydownEnter = handleDisabledEnterKeydown.bind(this); this.subscribe('editableKeydownEnter', this.instanceHandleEditableKeydownEnter); } } } function initExtensions() { this.extensions = []; // Passed in extensions Object.keys(this.options.extensions).forEach(function (name) { // Always save the toolbar extension for last if (name !== 'toolbar' && this.options.extensions[name]) { this.extensions.push(initExtension(this.options.extensions[name], name, this)); } }, this); // 4 Cases for imageDragging + fileDragging extensons: // // 1. ImageDragging ON + No Custom Image Dragging Extension: // * Use fileDragging extension (default options) // 2. ImageDragging OFF + No Custom Image Dragging Extension: // * Use fileDragging extension w/ images turned off // 3. ImageDragging ON + Custom Image Dragging Extension: // * Don't use fileDragging (could interfere with custom image dragging extension) // 4. ImageDragging OFF + Custom Image Dragging: // * Don't use fileDragging (could interfere with custom image dragging extension) if (shouldUseFileDraggingExtension.call(this)) { var opts = this.options.fileDragging; if (!opts) { opts = {}; // Image is in the 'allowedTypes' list by default. // If imageDragging is off override the 'allowedTypes' list with an empty one if (!isImageDraggingEnabled.call(this)) { opts.allowedTypes = []; } } this.addBuiltInExtension('fileDragging', opts); } // Built-in extensions var builtIns = { paste: true, 'anchor-preview': isAnchorPreviewEnabled.call(this), autoLink: isAutoLinkEnabled.call(this), keyboardCommands: isKeyboardCommandsEnabled.call(this), placeholder: isPlaceholderEnabled.call(this) }; Object.keys(builtIns).forEach(function (name) { if (builtIns[name]) { this.addBuiltInExtension(name); } }, this); // Users can pass in a custom toolbar extension // so check for that first and if it's not present // just create the default toolbar var toolbarExtension = this.options.extensions['toolbar']; if (!toolbarExtension && isToolbarEnabled.call(this)) { // Backwards compatability var toolbarOptions = MediumEditor.util.extend({}, this.options.toolbar, { allowMultiParagraphSelection: this.options.allowMultiParagraphSelection // deprecated }); toolbarExtension = new MediumEditor.extensions.toolbar(toolbarOptions); } // If the toolbar is not disabled, so we actually have an extension // initialize it and add it to the extensions array if (toolbarExtension) { this.extensions.push(initExtension(toolbarExtension, 'toolbar', this)); } } function mergeOptions(defaults, options) { var deprecatedProperties = [ ['allowMultiParagraphSelection', 'toolbar.allowMultiParagraphSelection'] ]; // warn about using deprecated properties if (options) { deprecatedProperties.forEach(function (pair) { if (options.hasOwnProperty(pair[0]) && options[pair[0]] !== undefined) { MediumEditor.util.deprecated(pair[0], pair[1], 'v6.0.0'); } }); } return MediumEditor.util.defaults({}, options, defaults); } function execActionInternal(action, opts) { /*jslint regexp: true*/ var appendAction = /^append-(.+)$/gi, justifyAction = /justify([A-Za-z]*)$/g, /* Detecting if is justifyCenter|Right|Left */ match, cmdValueArgument; /*jslint regexp: false*/ // Actions starting with 'append-' should attempt to format a block of text ('formatBlock') using a specific // type of block element (ie append-blockquote, append-h1, append-pre, etc.) match = appendAction.exec(action); if (match) { return MediumEditor.util.execFormatBlock(this.options.ownerDocument, match[1]); } if (action === 'fontSize') { // TODO: Deprecate support for opts.size in 6.0.0 if (opts.size) { MediumEditor.util.deprecated('.size option for fontSize command', '.value', '6.0.0'); } cmdValueArgument = opts.value || opts.size; return this.options.ownerDocument.execCommand('fontSize', false, cmdValueArgument); } if (action === 'fontName') { // TODO: Deprecate support for opts.name in 6.0.0 if (opts.name) { MediumEditor.util.deprecated('.name option for fontName command', '.value', '6.0.0'); } cmdValueArgument = opts.value || opts.name; return this.options.ownerDocument.execCommand('fontName', false, cmdValueArgument); } if (action === 'createLink') { return this.createLink(opts); } if (action === 'image') { var src = this.options.contentWindow.getSelection().toString().trim(); return this.options.ownerDocument.execCommand('insertImage', false, src); } if (action === 'html') { var html = this.options.contentWindow.getSelection().toString().trim(); return MediumEditor.util.insertHTMLCommand(this.options.ownerDocument, html); } /* Issue: https://github.com/yabwe/medium-editor/issues/595 * If the action is to justify the text */ if (justifyAction.exec(action)) { var result = this.options.ownerDocument.execCommand(action, false, null), parentNode = MediumEditor.selection.getSelectedParentElement(MediumEditor.selection.getSelectionRange(this.options.ownerDocument)); if (parentNode) { cleanupJustifyDivFragments.call(this, MediumEditor.util.getTopBlockContainer(parentNode)); } return result; } cmdValueArgument = opts && opts.value; return this.options.ownerDocument.execCommand(action, false, cmdValueArgument); } /* If we've just justified text within a container block * Chrome may have removed <br> elements and instead wrapped lines in <div> elements * with a text-align property. If so, we want to fix this */ function cleanupJustifyDivFragments(blockContainer) { if (!blockContainer) { return; } var textAlign, childDivs = Array.prototype.slice.call(blockContainer.childNodes).filter(function (element) { var isDiv = element.nodeName.toLowerCase() === 'div'; if (isDiv && !textAlign) { textAlign = element.style.textAlign; } return isDiv; }); /* If we found child <div> elements with text-align style attributes * we should fix this by: * * 1) Unwrapping each <div> which has a text-align style * 2) Insert a <br> element after each set of 'unwrapped' div children * 3) Set the text-align style of the parent block element */ if (childDivs.length) { // Since we're mucking with the HTML, preserve selection this.saveSelection(); childDivs.forEach(function (div) { if (div.style.textAlign === textAlign) { var lastChild = div.lastChild; if (lastChild) { // Instead of a div, extract the child elements and add a <br> MediumEditor.util.unwrap(div, this.options.ownerDocument); var br = this.options.ownerDocument.createElement('BR'); lastChild.parentNode.insertBefore(br, lastChild.nextSibling); } } }, this); blockContainer.style.textAlign = textAlign; // We're done, so restore selection this.restoreSelection(); } } var initialContent = {}; MediumEditor.prototype = { // NOT DOCUMENTED - exposed for backwards compatability init: function (elements, options) { this.options = mergeOptions.call(this, this.defaults, options); this.origElements = elements; if (!this.options.elementsContainer) { this.options.elementsContainer = this.options.ownerDocument.body; } return this.setup(); }, setup: function () { if (this.isActive) { return; } addToEditors.call(this, this.options.contentWindow); this.events = new MediumEditor.Events(this); this.elements = []; this.addElements(this.origElements); if (this.elements.length === 0) { return; } this.isActive = true; // Call initialization helpers initExtensions.call(this); attachHandlers.call(this); }, destroy: function () { if (!this.isActive) { return; } this.isActive = false; this.extensions.forEach(function (extension) { if (typeof extension.destroy === 'function') { extension.destroy(); } }, this); this.events.destroy(); this.elements.forEach(function (element) { // Reset elements content, fix for issue where after editor destroyed the red underlines on spelling errors are left if (this.options.spellcheck) { element.innerHTML = element.innerHTML; } // cleanup extra added attributes element.removeAttribute('contentEditable'); element.removeAttribute('spellcheck'); element.removeAttribute('data-medium-editor-element'); element.classList.remove('medium-editor-element'); element.removeAttribute('role'); element.removeAttribute('aria-multiline'); element.removeAttribute('medium-editor-index'); element.removeAttribute('data-medium-editor-editor-index'); // Remove any elements created for textareas if (element.getAttribute('medium-editor-textarea-id')) { cleanupTextareaElement(element); } }, this); this.elements = []; this.instanceHandleEditableKeydownEnter = null; this.instanceHandleEditableInput = null; removeFromEditors.call(this, this.options.contentWindow); }, on: function (target, event, listener, useCapture) { this.events.attachDOMEvent(target, event, listener, useCapture); return this; }, off: function (target, event, listener, useCapture) { this.events.detachDOMEvent(target, event, listener, useCapture); return this; }, subscribe: function (event, listener) { this.events.attachCustomEvent(event, listener); return this; }, unsubscribe: function (event, listener) { this.events.detachCustomEvent(event, listener); return this; }, trigger: function (name, data, editable) { this.events.triggerCustomEvent(name, data, editable); return this; }, delay: function (fn) { var self = this; return setTimeout(function () { if (self.isActive) { fn(); } }, this.options.delay); }, serialize: function () { var i, elementid, content = {}, len = this.elements.length; for (i = 0; i < len; i += 1) { elementid = (this.elements[i].id !== '') ? this.elements[i].id : 'element-' + i; content[elementid] = { value: this.elements[i].innerHTML.trim() }; } return content; }, getExtensionByName: function (name) { var extension; if (this.extensions && this.extensions.length) { this.extensions.some(function (ext) { if (ext.name === name) { extension = ext; return true; } return false; }); } return extension; }, /** * NOT DOCUMENTED - exposed as a helper for other extensions to use */ addBuiltInExtension: function (name, opts) { var extension = this.getExtensionByName(name), merged; if (extension) { return extension; } switch (name) { case 'anchor': merged = MediumEditor.util.extend({}, this.options.anchor, opts); extension = new MediumEditor.extensions.anchor(merged); break; case 'anchor-preview': extension = new MediumEditor.extensions.anchorPreview(this.options.anchorPreview); break; case 'autoLink': extension = new MediumEditor.extensions.autoLink(); break; case 'fileDragging': extension = new MediumEditor.extensions.fileDragging(opts); break; case 'fontname': extension = new MediumEditor.extensions.fontName(this.options.fontName); break; case 'fontsize': extension = new MediumEditor.extensions.fontSize(opts); break; case 'keyboardCommands': extension = new MediumEditor.extensions.keyboardCommands(this.options.keyboardCommands); break; case 'paste': extension = new MediumEditor.extensions.paste(this.options.paste); break; case 'placeholder': extension = new MediumEditor.extensions.placeholder(this.options.placeholder); break; default: // All of the built-in buttons for MediumEditor are extensions // so check to see if the extension we're creating is a built-in button if (MediumEditor.extensions.button.isBuiltInButton(name)) { if (opts) { merged = MediumEditor.util.defaults({}, opts, MediumEditor.extensions.button.prototype.defaults[name]); extension = new MediumEditor.extensions.button(merged); } else { extension = new MediumEditor.extensions.button(name); } } } if (extension) { this.extensions.push(initExtension(extension, name, this)); } return extension; }, stopSelectionUpdates: function () { this.preventSelectionUpdates = true; }, startSelectionUpdates: function () { this.preventSelectionUpdates = false; }, checkSelection: function () { var toolbar = this.getExtensionByName('toolbar'); if (toolbar) { toolbar.checkState(); } return this; }, // Wrapper around document.queryCommandState for checking whether an action has already // been applied to the current selection queryCommandState: function (action) { var fullAction = /^full-(.+)$/gi, match, queryState = null; // Actions starting with 'full-' need to be modified since this is a medium-editor concept match = fullAction.exec(action); if (match) { action = match[1]; } try { queryState = this.options.ownerDocument.queryCommandState(action); } catch (exc) { queryState = null; } return queryState; }, execAction: function (action, opts) { /*jslint regexp: true*/ var fullAction = /^full-(.+)$/gi, match, result; /*jslint regexp: false*/ // Actions starting with 'full-' should be applied to to the entire contents of the editable element // (ie full-bold, full-append-pre, etc.) match = fullAction.exec(action); if (match) { // Store the current selection to be restored after applying the action this.saveSelection(); // Select all of the contents before calling the action this.selectAllContents(); result = execActionInternal.call(this, match[1], opts); // Restore the previous selection this.restoreSelection(); } else { result = execActionInternal.call(this, action, opts); } // do some DOM clean-up for known browser issues after the action if (action === 'insertunorderedlist' || action === 'insertorderedlist') { MediumEditor.util.cleanListDOM(this.options.ownerDocument, this.getSelectedParentElement()); } this.checkSelection(); return result; }, getSelectedParentElement: function (range) { if (range === undefined) { range = this.options.contentWindow.getSelection().getRangeAt(0); } return MediumEditor.selection.getSelectedParentElement(range); }, selectAllContents: function () { var currNode = MediumEditor.selection.getSelectionElement(this.options.contentWindow); if (currNode) { // Move to the lowest descendant node that still selects all of the contents while (currNode.children.length === 1) { currNode = currNode.children[0]; } this.selectElement(currNode); } }, selectElement: function (element) { MediumEditor.selection.selectNode(element, this.options.ownerDocument); var selElement = MediumEditor.selection.getSelectionElement(this.options.contentWindow); if (selElement) { this.events.focusElement(selElement); } }, getFocusedElement: function () { var focused; this.elements.some(function (element) { // Find the element that has focus if (!focused && element.getAttribute('data-medium-focused')) { focused = element; } // bail if we found the element that had focus return !!focused; }, this); return focused; }, // Export the state of the selection in respect to one of this // instance of MediumEditor's elements exportSelection: function () { var selectionElement = MediumEditor.selection.getSelectionElement(this.options.contentWindow), editableElementIndex = this.elements.indexOf(selectionElement), selectionState = null; if (editableElementIndex >= 0) { selectionState = MediumEditor.selection.exportSelection(selectionElement, this.options.ownerDocument); } if (selectionState !== null && editableElementIndex !== 0) { selectionState.editableElementIndex = editableElementIndex; } return selectionState; }, saveSelection: function () { this.selectionState = this.exportSelection(); }, // Restore a selection based on a selectionState returned by a call // to MediumEditor.exportSelection importSelection: function (selectionState, favorLaterSelectionAnchor) { if (!selectionState) { return; } var editableElement = this.elements[selectionState.editableElementIndex || 0]; MediumEditor.selection.importSelection(selectionState, editableElement, this.options.ownerDocument, favorLaterSelectionAnchor); }, restoreSelection: function () { this.importSelection(this.selectionState); }, createLink: function (opts) { var currentEditor = MediumEditor.selection.getSelectionElement(this.options.contentWindow), customEvent = {}, targetUrl; // Make sure the selection is within an element this editor is tracking if (this.elements.indexOf(currentEditor) === -1) { return; } try { this.events.disableCustomEvent('editableInput'); // TODO: Deprecate support for opts.url in 6.0.0 if (opts.url) { MediumEditor.util.deprecated('.url option for createLink', '.value', '6.0.0'); } targetUrl = opts.url || opts.value; if (targetUrl && targetUrl.trim().length > 0) { var currentSelection = this.options.contentWindow.getSelection(); if (currentSelection) { var currRange = currentSelection.getRangeAt(0), commonAncestorContainer = currRange.commonAncestorContainer, exportedSelection, startContainerParentElement, endContainerParentElement, textNodes; // If the selection is contained within a single text node // and the selection starts at the beginning of the text node, // MSIE still says the startContainer is the parent of the text node. // If the selection is contained within a single text node, we // want to just use the default browser 'createLink', so we need // to account for this case and adjust the commonAncestorContainer accordingly if (currRange.endContainer.nodeType === 3 && currRange.startContainer.nodeType !== 3 && currRange.startOffset === 0 && currRange.startContainer.firstChild === currRange.endContainer) { commonAncestorContainer = currRange.endContainer; } startContainerParentElement = MediumEditor.util.getClosestBlockContainer(currRange.startContainer); endContainerParentElement = MediumEditor.util.getClosestBlockContainer(currRange.endContainer); // If the selection is not contained within a single text node // but the selection is contained within the same block element // we want to make sure we create a single link, and not multiple links // which can happen with the built in browser functionality if (commonAncestorContainer.nodeType !== 3 && commonAncestorContainer.textContent.length !== 0 && startContainerParentElement === endContainerParentElement) { var parentElement = (startContainerParentElement || currentEditor), fragment = this.options.ownerDocument.createDocumentFragment(); // since we are going to create a link from an extracted text, // be sure that if we are updating a link, we won't let an empty link behind (see #754) // (Workaroung for Chrome) this.execAction('unlink'); exportedSelection = this.exportSelection(); fragment.appendChild(parentElement.cloneNode(true)); if (currentEditor === parentElement) { // We have to avoid the editor itself being wiped out when it's the only block element, // as our reference inside this.elements gets detached from the page when insertHTML runs. // If we just use [parentElement, 0] and [parentElement, parentElement.childNodes.length] // as the range boundaries, this happens whenever parentElement === currentEditor. // The tradeoff to this workaround is that a orphaned tag can sometimes be left behind at // the end of the editor's content. // In Gecko: // as an empty <strong></strong> if parentElement.lastChild is a <strong> tag. // In WebKit: // an invented <br /> tag at the end in the same situation MediumEditor.selection.select( this.options.ownerDocument, parentElement.firstChild, 0, parentElement.lastChild, parentElement.lastChild.nodeType === 3 ? parentElement.lastChild.nodeValue.length : parentElement.lastChild.childNodes.length ); } else { MediumEditor.selection.select( this.options.ownerDocument, parentElement, 0, parentElement, parentElement.childNodes.length ); } var modifiedExportedSelection = this.exportSelection(); textNodes = MediumEditor.util.findOrCreateMatchingTextNodes( this.options.ownerDocument, fragment, { start: exportedSelection.start - modifiedExportedSelection.start, end: exportedSelection.end - modifiedExportedSelection.start, editableElementIndex: exportedSelection.editableElementIndex } ); // If textNodes are not present, when changing link on images // ex: <a><img src="http://image.test.com"></a>, change fragment to currRange.startContainer // and set textNodes array to [imageElement, imageElement] if (textNodes.length === 0) { fragment = this.options.ownerDocument.createDocumentFragment(); fragment.appendChild(commonAncestorContainer.cloneNode(true)); textNodes = [fragment.firstChild.firstChild, fragment.firstChild.lastChild]; } // Creates the link in the document fragment MediumEditor.util.createLink(this.options.ownerDocument, textNodes, targetUrl.trim()); // Chrome trims the leading whitespaces when inserting HTML, which messes up restoring the selection. var leadingWhitespacesCount = (fragment.firstChild.innerHTML.match(/^\s+/) || [''])[0].length; // Now move the created link back into the original document in a way to preserve undo/redo history MediumEditor.util.insertHTMLCommand(this.options.ownerDocument, fragment.firstChild.innerHTML.replace(/^\s+/, '')); exportedSelection.start -= leadingWhitespacesCount; exportedSelection.end -= leadingWhitespacesCount; th