medium-editor
Version:
Medium.com WYSIWYG editor clone.
1,149 lines (972 loc) • 56.2 kB
JavaScript
(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