Skip to content

Commit

Permalink
Merge pull request yabwe#597 from daviferreira/editableInput
Browse files Browse the repository at this point in the history
Create custom 'oninput' event which works in all supported browsers!
  • Loading branch information
daviferreira committed May 9, 2015
2 parents afae371 + d492a03 commit e6a4017
Show file tree
Hide file tree
Showing 9 changed files with 798 additions and 41 deletions.
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,23 +200,35 @@ Check out the Wiki page for a list of available themes: [https://github.com/davi
* __.delay(fn)__: delay any function from being executed by the amount of time passed as the `delay` option
* __.getSelectionParentElement(range)__: get the parent contenteditable element that contains the current selection
* __.getExtensionByName(name)__: get a reference to an extension with the specified name
* __.getFocusedElement()__: returns an element if any contenteditable element monitored by MediumEditor currently has focused
* __.selectElement(element)__: change selection to be a specific element and update the toolbar to reflect the selection
* __.exportSelection()__: return a data representation of the selected text, which can be applied via `importSelection()`
* __.importSelection(selectionState)__: restore the selection using a data representation of previously selected text (ie value returned by `exportSelection()`)

## Capturing DOM changes

For observing any changes on contentEditable
For observing any changes on contentEditable, use the custom 'editableInput' event exposed via the `subscribe()` method:

```js
$('.editable').on('input', function() {
// Do some work
var editor = new MediumEditor('.editable');
editor.subscribe('editableInput', function (event, editable) {
// Do some work
});
```

This is handy when you need to capture modifications to the contenteditable element that occur outside of `key up`'s scope (like clicking on toolbar buttons).
This event is supported in all browsers supported by MediumEditor (including IE9+)! To help with cases when one instance of MediumEditor is monitoring multiple elements, the 2nd argument passed to the event handler (`editable` in the example above) will be a reference to the contenteditable element that has actually changed.

`input` is supported by Chrome, Firefox, and other modern browsers (However, [it is not supported in IE 9-11](https://connect.microsoft.com/IE/feedback/details/794285/ie10-11-input-event-does-not-fire-on-div-with-contenteditable-set)). If you want to read more or support older browsers, check [Listening to events of a contenteditable HTML element](http://stackoverflow.com/questions/7802784/listening-to-events-of-a-contenteditable-html-element/7804973#7804973) and [Detect changes in the DOM](http://stackoverflow.com/questions/3219758/detect-changes-in-the-dom)
This is handy when you need to capture any modifications to the contenteditable element including:
* Typing
* Cutting/Pasting
* Changes from clicking on buttons in the toolbar
* Undo/Redo

Why is this interesting and why should you use this event instead of just attaching to the `input` event on the contenteditable element?

So for most modern browsers (Chrome, Firefox, Safari, etc.), the `input` event works just fine. Infact, `editableInput` is just a proxy for the `input` event in those browsers. However, the `input` event [is not supported for contenteditable elements in IE 9-11](https://connect.microsoft.com/IE/feedback/details/794285/ie10-11-input-event-does-not-fire-on-div-with-contenteditable-set).

So, to properly support the `editableInput` event in Internet Explorer, MediumEditor uses a combination of the `selectionchange` and `keypress` events, as well as monitoring calls to `document.execCommand`.

## Extensions & Plugins

Expand Down
271 changes: 257 additions & 14 deletions dist/js/medium-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -1515,6 +1515,8 @@ var Events;

Events.prototype = {

InputEventOnContenteditableSupported: false,

// Helpers for event handling

attachDOMEvent: function (target, event, listener, useCapture) {
Expand Down Expand Up @@ -1591,6 +1593,108 @@ var Events;
}
},

// Cleaning up

destroy: function () {
this.detachAllDOMEvents();
this.detachAllCustomEvents();
this.detachExecCommand();
},

// 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;
}

// 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
var wrapper = function (aCommandName, aShowDefaultUI, aValueArgument) {
var result = doc.execCommand.orig.apply(this, arguments);

if (doc.execCommand.listeners) {
var args = Array.prototype.slice.call(arguments);
doc.execCommand.listeners.forEach(function (listener) {
listener({
command: aCommandName,
value: aValueArgument,
args: args,
result: result
});
});
}

return result;
};

// Store a reference to the original execCommand
wrapper.orig = doc.execCommand;

// Attach an array for storing listeners
wrapper.listeners = [];

// 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) {
Expand All @@ -1614,6 +1718,30 @@ var Events;
case 'focus':
// Detecting when focus moves into some part of MediumEditor
this.setupListener('externalInteraction');
this.listeners[name] = true;
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;

// Attach to the 'oninput' event, handled correctly by most browsers
if (this.InputEventOnContenteditableSupported) {
this.attachDOMEvent(element, 'input', this.handleInput.bind(this));
}
}.bind(this));

// 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();
}

this.listeners[name] = true;
break;
case 'editableClick':
Expand Down Expand Up @@ -1707,19 +1835,9 @@ var Events;
var toolbarEl = this.base.toolbar ? this.base.toolbar.getToolbarElement() : null,
anchorPreview = this.base.getExtensionByName('anchor-preview'),
previewEl = (anchorPreview && anchorPreview.getPreviewElement) ? anchorPreview.getPreviewElement() : null,
hadFocus,
hadFocus = this.base.getFocusedElement(),
toFocus;

this.base.elements.some(function (element) {
// Find the element that has focus
if (!hadFocus && element.getAttribute('data-medium-focused')) {
hadFocus = element;
}

// bail if we found the element that had focus
return !!hadFocus;
}, this);

// For clicks, we need to know if the mousedown that caused the click happened inside the existing focused element.
// If so, we don't want to focus another element
if (hadFocus &&
Expand Down Expand Up @@ -1770,6 +1888,53 @@ var Events;
}
},

updateInput: function (target, eventObj) {
// 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');
if (target.innerHTML !== this.contentCache[index]) {
// The content has changed since the last time we checked, fire the event
this.triggerCustomEvent('editableInput', eventObj, target);
}
this.contentCache[index] = target.innerHTML;
},

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 (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);
},
Expand All @@ -1782,6 +1947,10 @@ var Events;
this.lastMousedownTarget = event.target;
},

handleInput: function (event) {
this.updateInput(event.currentTarget, event);
},

handleClick: function (event) {
this.triggerCustomEvent('editableClick', event, event.currentTarget);
},
Expand All @@ -1792,6 +1961,18 @@ var Events;

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) {
Expand Down Expand Up @@ -1832,6 +2013,53 @@ var Events;
}
};

// Do feature detection to determine if the 'input' event is supported correctly
// Currently, IE does not support this event on contenteditable elements

var tempFunction = function () {
Events.prototype.InputEventOnContenteditableSupported = true;
},
tempElement,
existingRanges = [];

// Create a temporary contenteditable element with an 'oninput' event listener
tempElement = document.createElement('div');
tempElement.setAttribute('contenteditable', true);
tempElement.innerHTML = 't';
tempElement.addEventListener('input', tempFunction);
tempElement.style.position = 'absolute';
tempElement.style.left = '-100px';
tempElement.style.top = '-100px';
document.body.appendChild(tempElement);

// Store any existing ranges that may exist
var selection = document.getSelection();
for (var i = 0; i < selection.rangeCount; i++) {
existingRanges.push(selection.getRangeAt(i));
}

// Create a new range containing the content of the temporary contenteditable element
// and replace the selection to only contain this range
var range = document.createRange();
range.selectNodeContents(tempElement);
selection.removeAllRanges();
selection.addRange(range);

// Call 'execCommand' on the current selection, which will cause the input event to be triggered if it's supported
document.execCommand('bold', false, null);

// Cleanup the temporary element
tempElement.removeEventListener('input', tempFunction);
tempElement.parentNode.removeChild(tempElement);
selection.removeAllRanges();

// Restore any existing ranges
if (existingRanges.length) {
for (i = 0; i < existingRanges.length; i++) {
selection.addRange(existingRanges[i]);
}
}

}());

var DefaultButton;
Expand Down Expand Up @@ -3888,7 +4116,7 @@ function MediumEditor(elements, options) {
}

function initElements() {
this.elements.forEach(function (element) {
this.elements.forEach(function (element, index) {
if (!this.options.disableEditing && !element.getAttribute('data-disable-editing')) {
element.setAttribute('contentEditable', true);
element.setAttribute('spellcheck', this.options.spellcheck);
Expand All @@ -3899,6 +4127,7 @@ function MediumEditor(elements, options) {
element.setAttribute('data-medium-element', true);
element.setAttribute('role', 'textbox');
element.setAttribute('aria-multiline', true);
element.setAttribute('medium-editor-index', index);

if (element.hasAttribute('medium-editor-textarea-id')) {
this.on(element, 'input', function (event) {
Expand Down Expand Up @@ -4164,8 +4393,7 @@ function MediumEditor(elements, options) {
}
}, this);

this.events.detachAllDOMEvents();
this.events.detachAllCustomEvents();
this.events.destroy();
},

on: function (target, event, listener, useCapture) {
Expand Down Expand Up @@ -4361,6 +4589,21 @@ function MediumEditor(elements, options) {
}
},

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;
},

// http://stackoverflow.com/questions/17678843/cant-restore-selection-after-html-modify-even-if-its-the-same-html
// Tim Down
// TODO: move to selection.js and clean up old methods there
Expand Down
7 changes: 4 additions & 3 deletions dist/js/medium-editor.min.js

Large diffs are not rendered by default.

Loading

0 comments on commit e6a4017

Please sign in to comment.