medium-editor
Version:
Medium.com WYSIWYG editor clone.
600 lines (505 loc) • 24.7 kB
JavaScript
(function () {
'use strict';
function isElementDescendantOfExtension(extensions, element) {
if (!extensions) {
return false;
}
return extensions.some(function (extension) {
if (typeof extension.getInteractionElements !== 'function') {
return false;
}
var extensionElements = extension.getInteractionElements();
if (!extensionElements) {
return false;
}
if (!Array.isArray(extensionElements)) {
extensionElements = [extensionElements];
}
return extensionElements.some(function (el) {
return MediumEditor.util.isDescendant(el, element, true);
});
});
}
var Events = function (instance) {
this.base = instance;
this.options = this.base.options;
this.events = [];
this.disabledEvents = {};
this.customEvents = {};
this.listeners = {};
};
Events.prototype = {
InputEventOnContenteditableSupported: !MediumEditor.util.isIE && !MediumEditor.util.isEdge,
// Helpers for event handling
attachDOMEvent: function (targets, event, listener, useCapture) {
var win = this.base.options.contentWindow,
doc = this.base.options.ownerDocument;
targets = MediumEditor.util.isElement(targets) || [win, doc].indexOf(targets) > -1 ? [targets] : targets;
Array.prototype.forEach.call(targets, function (target) {
target.addEventListener(event, listener, useCapture);
this.events.push([target, event, listener, useCapture]);
}.bind(this));
},
detachDOMEvent: function (targets, event, listener, useCapture) {
var index, e,
win = this.base.options.contentWindow,
doc = this.base.options.ownerDocument;
if (targets) {
targets = MediumEditor.util.isElement(targets) || [win, doc].indexOf(targets) > -1 ? [targets] : targets;
Array.prototype.forEach.call(targets, function (target) {
index = this.indexOfListener(target, event, listener, useCapture);
if (index !== -1) {
e = this.events.splice(index, 1)[0];
e[0].removeEventListener(e[1], e[2], e[3]);
}
}.bind(this));
}
},
indexOfListener: function (target, event, listener, useCapture) {
var i, n, item;
for (i = 0, n = this.events.length; i < n; i = i + 1) {
item = this.events[i];
if (item[0] === target && item[1] === event && item[2] === listener && item[3] === useCapture) {
return i;
}
}
return -1;
},
detachAllDOMEvents: function () {
var e = this.events.pop();
while (e) {
e[0].removeEventListener(e[1], e[2], e[3]);
e = this.events.pop();
}
},
detachAllEventsFromElement: function (element) {
var filtered = this.events.filter(function (e) {
return e && e[0].getAttribute && e[0].getAttribute('medium-editor-index') === element.getAttribute('medium-editor-index');
});
for (var i = 0, len = filtered.length; i < len; i++) {
var e = filtered[i];
this.detachDOMEvent(e[0], e[1], e[2], e[3]);
}
},
// Attach all existing handlers to a new element
attachAllEventsToElement: function (element) {
if (this.listeners['editableInput']) {
this.contentCache[element.getAttribute('medium-editor-index')] = element.innerHTML;
}
if (this.eventsCache) {
this.eventsCache.forEach(function (e) {
this.attachDOMEvent(element, e['name'], e['handler'].bind(this));
}, this);
}
},
enableCustomEvent: function (event) {
if (this.disabledEvents[event] !== undefined) {
delete this.disabledEvents[event];
}
},
disableCustomEvent: function (event) {
this.disabledEvents[event] = true;
},
// custom events
attachCustomEvent: function (event, listener) {
this.setupListener(event);
if (!this.customEvents[event]) {
this.customEvents[event] = [];
}
this.customEvents[event].push(listener);
},
detachCustomEvent: function (event, listener) {
var index = this.indexOfCustomListener(event, listener);
if (index !== -1) {
this.customEvents[event].splice(index, 1);
// TODO: If array is empty, should detach internal listeners via destroyListener()
}
},
indexOfCustomListener: function (event, listener) {
if (!this.customEvents[event] || !this.customEvents[event].length) {
return -1;
}
return this.customEvents[event].indexOf(listener);
},
detachAllCustomEvents: function () {
this.customEvents = {};
// TODO: Should detach internal listeners here via destroyListener()
},
triggerCustomEvent: function (name, data, editable) {
if (this.customEvents[name] && !this.disabledEvents[name]) {
this.customEvents[name].forEach(function (listener) {
listener(data, editable);
});
}
},
// Cleaning up
destroy: function () {
this.detachAllDOMEvents();
this.detachAllCustomEvents();
this.detachExecCommand();
if (this.base.elements) {
this.base.elements.forEach(function (element) {
element.removeAttribute('data-medium-focused');
});
}
},
// Listening to calls to document.execCommand
// Attach a listener to be notified when document.execCommand is called
attachToExecCommand: function () {
if (this.execCommandListener) {
return;
}
// Store an instance of the listener so:
// 1) We only attach to execCommand once
// 2) We can remove the listener later
this.execCommandListener = function (execInfo) {
this.handleDocumentExecCommand(execInfo);
}.bind(this);
// Ensure that execCommand has been wrapped correctly
this.wrapExecCommand();
// Add listener to list of execCommand listeners
this.options.ownerDocument.execCommand.listeners.push(this.execCommandListener);
},
// Remove our listener for calls to document.execCommand
detachExecCommand: function () {
var doc = this.options.ownerDocument;
if (!this.execCommandListener || !doc.execCommand.listeners) {
return;
}
// Find the index of this listener in the array of listeners so it can be removed
var index = doc.execCommand.listeners.indexOf(this.execCommandListener);
if (index !== -1) {
doc.execCommand.listeners.splice(index, 1);
}
// If the list of listeners is now empty, put execCommand back to its original state
if (!doc.execCommand.listeners.length) {
this.unwrapExecCommand();
}
},
// Wrap document.execCommand in a custom method so we can listen to calls to it
wrapExecCommand: function () {
var doc = this.options.ownerDocument;
// Ensure all instance of MediumEditor only wrap execCommand once
if (doc.execCommand.listeners) {
return;
}
// Helper method to call all listeners to execCommand
var callListeners = function (args, result) {
if (doc.execCommand.listeners) {
doc.execCommand.listeners.forEach(function (listener) {
listener({
command: args[0],
value: args[2],
args: args,
result: result
});
});
}
},
// Create a wrapper method for execCommand which will:
// 1) Call document.execCommand with the correct arguments
// 2) Loop through any listeners and notify them that execCommand was called
// passing extra info on the call
// 3) Return the result
wrapper = function () {
var result = doc.execCommand.orig.apply(this, arguments);
if (!doc.execCommand.listeners) {
return result;
}
var args = Array.prototype.slice.call(arguments);
callListeners(args, result);
return result;
};
// Store a reference to the original execCommand
wrapper.orig = doc.execCommand;
// Attach an array for storing listeners
wrapper.listeners = [];
// Helper for notifying listeners
wrapper.callListeners = callListeners;
// Overwrite execCommand
doc.execCommand = wrapper;
},
// Revert document.execCommand back to its original self
unwrapExecCommand: function () {
var doc = this.options.ownerDocument;
if (!doc.execCommand.orig) {
return;
}
// Use the reference to the original execCommand to revert back
doc.execCommand = doc.execCommand.orig;
},
// Listening to browser events to emit events medium-editor cares about
setupListener: function (name) {
if (this.listeners[name]) {
return;
}
switch (name) {
case 'externalInteraction':
// Detecting when user has interacted with elements outside of MediumEditor
this.attachDOMEvent(this.options.ownerDocument.body, 'mousedown', this.handleBodyMousedown.bind(this), true);
this.attachDOMEvent(this.options.ownerDocument.body, 'click', this.handleBodyClick.bind(this), true);
this.attachDOMEvent(this.options.ownerDocument.body, 'focus', this.handleBodyFocus.bind(this), true);
break;
case 'blur':
// Detecting when focus is lost
this.setupListener('externalInteraction');
break;
case 'focus':
// Detecting when focus moves into some part of MediumEditor
this.setupListener('externalInteraction');
break;
case 'editableInput':
// setup cache for knowing when the content has changed
this.contentCache = {};
this.base.elements.forEach(function (element) {
this.contentCache[element.getAttribute('medium-editor-index')] = element.innerHTML;
}, this);
// Attach to the 'oninput' event, handled correctly by most browsers
if (this.InputEventOnContenteditableSupported) {
this.attachToEachElement('input', this.handleInput);
}
// For browsers which don't support the input event on contenteditable (IE)
// we'll attach to 'selectionchange' on the document and 'keypress' on the editables
if (!this.InputEventOnContenteditableSupported) {
this.setupListener('editableKeypress');
this.keypressUpdateInput = true;
this.attachDOMEvent(document, 'selectionchange', this.handleDocumentSelectionChange.bind(this));
// Listen to calls to execCommand
this.attachToExecCommand();
}
break;
case 'editableClick':
// Detecting click in the contenteditables
this.attachToEachElement('click', this.handleClick);
break;
case 'editableBlur':
// Detecting blur in the contenteditables
this.attachToEachElement('blur', this.handleBlur);
break;
case 'editableKeypress':
// Detecting keypress in the contenteditables
this.attachToEachElement('keypress', this.handleKeypress);
break;
case 'editableKeyup':
// Detecting keyup in the contenteditables
this.attachToEachElement('keyup', this.handleKeyup);
break;
case 'editableKeydown':
// Detecting keydown on the contenteditables
this.attachToEachElement('keydown', this.handleKeydown);
break;
case 'editableKeydownSpace':
// Detecting keydown for SPACE on the contenteditables
this.setupListener('editableKeydown');
break;
case 'editableKeydownEnter':
// Detecting keydown for ENTER on the contenteditables
this.setupListener('editableKeydown');
break;
case 'editableKeydownTab':
// Detecting keydown for TAB on the contenteditable
this.setupListener('editableKeydown');
break;
case 'editableKeydownDelete':
// Detecting keydown for DELETE/BACKSPACE on the contenteditables
this.setupListener('editableKeydown');
break;
case 'editableMouseover':
// Detecting mouseover on the contenteditables
this.attachToEachElement('mouseover', this.handleMouseover);
break;
case 'editableDrag':
// Detecting dragover and dragleave on the contenteditables
this.attachToEachElement('dragover', this.handleDragging);
this.attachToEachElement('dragleave', this.handleDragging);
break;
case 'editableDrop':
// Detecting drop on the contenteditables
this.attachToEachElement('drop', this.handleDrop);
break;
// TODO: We need to have a custom 'paste' event separate from 'editablePaste'
// Need to think about the way to introduce this without breaking folks
case 'editablePaste':
// Detecting paste on the contenteditables
this.attachToEachElement('paste', this.handlePaste);
break;
}
this.listeners[name] = true;
},
attachToEachElement: function (name, handler) {
// build our internal cache to know which element got already what handler attached
if (!this.eventsCache) {
this.eventsCache = [];
}
this.base.elements.forEach(function (element) {
this.attachDOMEvent(element, name, handler.bind(this));
}, this);
this.eventsCache.push({ 'name': name, 'handler': handler });
},
cleanupElement: function (element) {
var index = element.getAttribute('medium-editor-index');
if (index) {
this.detachAllEventsFromElement(element);
if (this.contentCache) {
delete this.contentCache[index];
}
}
},
focusElement: function (element) {
element.focus();
this.updateFocus(element, { target: element, type: 'focus' });
},
updateFocus: function (target, eventObj) {
var hadFocus = this.base.getFocusedElement(),
toFocus;
// For clicks, we need to know if the mousedown that caused the click happened inside the existing focused element
// or one of the extension elements. If so, we don't want to focus another element
if (hadFocus &&
eventObj.type === 'click' &&
this.lastMousedownTarget &&
(MediumEditor.util.isDescendant(hadFocus, this.lastMousedownTarget, true) ||
isElementDescendantOfExtension(this.base.extensions, this.lastMousedownTarget))) {
toFocus = hadFocus;
}
if (!toFocus) {
this.base.elements.some(function (element) {
// If the target is part of an editor element, this is the element getting focus
if (!toFocus && (MediumEditor.util.isDescendant(element, target, true))) {
toFocus = element;
}
// bail if we found an element that's getting focus
return !!toFocus;
}, this);
}
// Check if the target is external (not part of the editor, toolbar, or any other extension)
var externalEvent = !MediumEditor.util.isDescendant(hadFocus, target, true) &&
!isElementDescendantOfExtension(this.base.extensions, target);
if (toFocus !== hadFocus) {
// If element has focus, and focus is going outside of editor
// Don't blur focused element if clicking on editor, toolbar, or anchorpreview
if (hadFocus && externalEvent) {
// Trigger blur on the editable that has lost focus
hadFocus.removeAttribute('data-medium-focused');
this.triggerCustomEvent('blur', eventObj, hadFocus);
}
// If focus is going into an editor element
if (toFocus) {
// Trigger focus on the editable that now has focus
toFocus.setAttribute('data-medium-focused', true);
this.triggerCustomEvent('focus', eventObj, toFocus);
}
}
if (externalEvent) {
this.triggerCustomEvent('externalInteraction', eventObj);
}
},
updateInput: function (target, eventObj) {
if (!this.contentCache) {
return;
}
// An event triggered which signifies that the user may have changed someting
// Look in our cache of input for the contenteditables to see if something changed
var index = target.getAttribute('medium-editor-index'),
html = target.innerHTML;
if (html !== this.contentCache[index]) {
// The content has changed since the last time we checked, fire the event
this.triggerCustomEvent('editableInput', eventObj, target);
}
this.contentCache[index] = html;
},
handleDocumentSelectionChange: function (event) {
// When selectionchange fires, target and current target are set
// to document, since this is where the event is handled
// However, currentTarget will have an 'activeElement' property
// which will point to whatever element has focus.
if (event.currentTarget && event.currentTarget.activeElement) {
var activeElement = event.currentTarget.activeElement,
currentTarget;
// We can look at the 'activeElement' to determine if the selectionchange has
// happened within a contenteditable owned by this instance of MediumEditor
this.base.elements.some(function (element) {
if (MediumEditor.util.isDescendant(element, activeElement, true)) {
currentTarget = element;
return true;
}
return false;
}, this);
// We know selectionchange fired within one of our contenteditables
if (currentTarget) {
this.updateInput(currentTarget, { target: activeElement, currentTarget: currentTarget });
}
}
},
handleDocumentExecCommand: function () {
// document.execCommand has been called
// If one of our contenteditables currently has focus, we should
// attempt to trigger the 'editableInput' event
var target = this.base.getFocusedElement();
if (target) {
this.updateInput(target, { target: target, currentTarget: target });
}
},
handleBodyClick: function (event) {
this.updateFocus(event.target, event);
},
handleBodyFocus: function (event) {
this.updateFocus(event.target, event);
},
handleBodyMousedown: function (event) {
this.lastMousedownTarget = event.target;
},
handleInput: function (event) {
this.updateInput(event.currentTarget, event);
},
handleClick: function (event) {
this.triggerCustomEvent('editableClick', event, event.currentTarget);
},
handleBlur: function (event) {
this.triggerCustomEvent('editableBlur', event, event.currentTarget);
},
handleKeypress: function (event) {
this.triggerCustomEvent('editableKeypress', event, event.currentTarget);
// If we're doing manual detection of the editableInput event we need
// to check for input changes during 'keypress'
if (this.keypressUpdateInput) {
var eventObj = { target: event.target, currentTarget: event.currentTarget };
// In IE, we need to let the rest of the event stack complete before we detect
// changes to input, so using setTimeout here
setTimeout(function () {
this.updateInput(eventObj.currentTarget, eventObj);
}.bind(this), 0);
}
},
handleKeyup: function (event) {
this.triggerCustomEvent('editableKeyup', event, event.currentTarget);
},
handleMouseover: function (event) {
this.triggerCustomEvent('editableMouseover', event, event.currentTarget);
},
handleDragging: function (event) {
this.triggerCustomEvent('editableDrag', event, event.currentTarget);
},
handleDrop: function (event) {
this.triggerCustomEvent('editableDrop', event, event.currentTarget);
},
handlePaste: function (event) {
this.triggerCustomEvent('editablePaste', event, event.currentTarget);
},
handleKeydown: function (event) {
this.triggerCustomEvent('editableKeydown', event, event.currentTarget);
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.SPACE)) {
return this.triggerCustomEvent('editableKeydownSpace', event, event.currentTarget);
}
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.ENTER) || (event.ctrlKey && MediumEditor.util.isKey(event, MediumEditor.util.keyCode.M))) {
return this.triggerCustomEvent('editableKeydownEnter', event, event.currentTarget);
}
if (MediumEditor.util.isKey(event, MediumEditor.util.keyCode.TAB)) {
return this.triggerCustomEvent('editableKeydownTab', event, event.currentTarget);
}
if (MediumEditor.util.isKey(event, [MediumEditor.util.keyCode.DELETE, MediumEditor.util.keyCode.BACKSPACE])) {
return this.triggerCustomEvent('editableKeydownDelete', event, event.currentTarget);
}
}
};
MediumEditor.Events = Events;
}());