Skip to content

Commit

Permalink
Merge pull request #763 from patrick-webs/fix-editableInput-firing-to…
Browse files Browse the repository at this point in the history
…o-early-link-creation

Fire editableInput only once, at the end of link creation
  • Loading branch information
nmielnik committed Aug 7, 2015
2 parents 5cd305c + d54f319 commit 44fa918
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 107 deletions.
43 changes: 43 additions & 0 deletions spec/anchor.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,49 @@ describe('Anchor Button TestCase', function () {
expect(link.classList.contains('btn-default')).toBe(true);
});

it('should fire editableInput only once when the user creates a link open to a new window,' +
' and it should fire at the end of the DOM and selection modifications', function () {
spyOn(MediumEditor.prototype, 'createLink').and.callThrough();
this.el.innerHTML = '<p>Lorem ipsum et dolitur sunt.</p>';
var editor = this.newMediumEditor('.editor', {
anchor: {
targetCheckbox: true
}
}),
p = this.el.lastChild,
anchorExtension = editor.getExtensionByName('anchor'),
toolbar = editor.getExtensionByName('toolbar'),
selectionWhenEventsFired = [],
listener = function () {
selectionWhenEventsFired.push(window.getSelection().toString());
};

MediumEditor.selection.select(document, p.firstChild, 'Lorem '.length, p.firstChild, 'Lorem ipsum'.length);
fireEvent(editor.elements[0], 'focus');
jasmine.clock().tick(1);

// Click the 'anchor' button in the toolbar
fireEvent(toolbar.getToolbarElement().querySelector('[data-action="createLink"]'), 'click');

// Input a url and save
var input = anchorExtension.getInput(),
checkbox = anchorExtension.getAnchorTargetCheckbox();
input.value = 'http://www.example.com';
checkbox.checked = true;
editor.subscribe('editableInput', listener);
fireEvent(input, 'keyup', {
keyCode: Util.keyCode.ENTER
});

expect(editor.createLink).toHaveBeenCalledWith({
url: 'http://www.example.com',
target: '_blank'
});
expect(window.getSelection().toString()).toBe('ipsum', 'selected text should remain selected');
expect(selectionWhenEventsFired.length).toBe(1, 'only one editableInput event should have been registered');
expect(selectionWhenEventsFired[0]).toBe('ipsum', 'selected text should have been the same when event fired');
});

it('should not select empty paragraphs when link is created at beginning of paragraph', function () {
spyOn(MediumEditor.prototype, 'createLink').and.callThrough();
this.el.innerHTML = '<p>Some text</p><p><br/></p><p><br/></p><p>link text more text</p>';
Expand Down
14 changes: 14 additions & 0 deletions spec/events.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ describe('Events TestCase', function () {
editor.trigger('myIncredibleEvent', tempData, editor.elements[0]);
expect(spy).toHaveBeenCalledWith(tempData, editor.elements[0]);
});

it('can be disabled for a temporary period of time on a named basis', function () {
var editor = this.newMediumEditor('.editor'),
spy = jasmine.createSpy('handler'),
tempData = { temp: 'data' };
editor.subscribe('myIncredibleEvent', spy);
expect(spy).not.toHaveBeenCalled();
editor.events.disableCustomEvent('myIncredibleEvent');
editor.trigger('myIncredibleEvent', tempData, editor.elements[0]);
expect(spy).not.toHaveBeenCalled();
editor.events.enableCustomEvent('myIncredibleEvent');
editor.trigger('myIncredibleEvent', tempData, editor.elements[0]);
expect(spy).toHaveBeenCalledWith(tempData, editor.elements[0]);
});
});

describe('Custom Focus/Blur Listener', function () {
Expand Down
219 changes: 113 additions & 106 deletions src/js/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -897,126 +897,133 @@ function MediumEditor(elements, options) {
},

createLink: function (opts) {
var customEvent, i;

if (opts.url && opts.url.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;
}
var currentEditor, customEvent, i;

startContainerParentElement = Util.getClosestBlockContainer(currRange.startContainer);
endContainerParentElement = 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 && startContainerParentElement === endContainerParentElement) {

var currentEditor = Selection.getSelectionElement(this.options.contentWindow),
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
Selection.select(
this.options.ownerDocument,
parentElement.firstChild,
0,
parentElement.lastChild,
parentElement.lastChild.nodeType === 3 ?
parentElement.lastChild.nodeValue.length : parentElement.lastChild.childNodes.length
);
} else {
Selection.select(
this.options.ownerDocument,
parentElement,
0,
parentElement,
parentElement.childNodes.length
);
try {
this.events.disableCustomEvent('editableInput');
if (opts.url && opts.url.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;
}

var modifiedExportedSelection = this.exportSelection();

textNodes = Util.findOrCreateMatchingTextNodes(
this.options.ownerDocument,
fragment,
{
start: exportedSelection.start - modifiedExportedSelection.start,
end: exportedSelection.end - modifiedExportedSelection.start,
editableElementIndex: exportedSelection.editableElementIndex
startContainerParentElement = Util.getClosestBlockContainer(currRange.startContainer);
endContainerParentElement = 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 && startContainerParentElement === endContainerParentElement) {

currentEditor = Selection.getSelectionElement(this.options.contentWindow);
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
Selection.select(
this.options.ownerDocument,
parentElement.firstChild,
0,
parentElement.lastChild,
parentElement.lastChild.nodeType === 3 ?
parentElement.lastChild.nodeValue.length : parentElement.lastChild.childNodes.length
);
} else {
Selection.select(
this.options.ownerDocument,
parentElement,
0,
parentElement,
parentElement.childNodes.length
);
}
);

// Creates the link in the document fragment
Util.createLink(this.options.ownerDocument, textNodes, opts.url.trim());
var modifiedExportedSelection = this.exportSelection();

// Chrome trims the leading whitespaces when inserting HTML, which messes up restoring the selection.
var leadingWhitespacesCount = (fragment.firstChild.innerHTML.match(/^\s+/) || [''])[0].length;
textNodes = Util.findOrCreateMatchingTextNodes(
this.options.ownerDocument,
fragment,
{
start: exportedSelection.start - modifiedExportedSelection.start,
end: exportedSelection.end - modifiedExportedSelection.start,
editableElementIndex: exportedSelection.editableElementIndex
}
);

// Now move the created link back into the original document in a way to preserve undo/redo history
Util.insertHTMLCommand(this.options.ownerDocument, fragment.firstChild.innerHTML.replace(/^\s+/, ''));
exportedSelection.start -= leadingWhitespacesCount;
exportedSelection.end -= leadingWhitespacesCount;
// Creates the link in the document fragment
Util.createLink(this.options.ownerDocument, textNodes, opts.url.trim());

this.importSelection(exportedSelection);
} else {
this.options.ownerDocument.execCommand('createLink', false, opts.url);
}
// Chrome trims the leading whitespaces when inserting HTML, which messes up restoring the selection.
var leadingWhitespacesCount = (fragment.firstChild.innerHTML.match(/^\s+/) || [''])[0].length;

if (this.options.targetBlank || opts.target === '_blank') {
Util.setTargetBlank(Selection.getSelectionStart(this.options.ownerDocument), opts.url);
}
// Now move the created link back into the original document in a way to preserve undo/redo history
Util.insertHTMLCommand(this.options.ownerDocument, fragment.firstChild.innerHTML.replace(/^\s+/, ''));
exportedSelection.start -= leadingWhitespacesCount;
exportedSelection.end -= leadingWhitespacesCount;

this.importSelection(exportedSelection);
} else {
this.options.ownerDocument.execCommand('createLink', false, opts.url);
}

if (this.options.targetBlank || opts.target === '_blank') {
Util.setTargetBlank(Selection.getSelectionStart(this.options.ownerDocument), opts.url);
}

if (opts.buttonClass) {
Util.addClassToAnchors(Selection.getSelectionStart(this.options.ownerDocument), opts.buttonClass);
if (opts.buttonClass) {
Util.addClassToAnchors(Selection.getSelectionStart(this.options.ownerDocument), opts.buttonClass);
}
}
}
}

if (this.options.targetBlank || opts.target === '_blank' || opts.buttonClass) {
customEvent = this.options.ownerDocument.createEvent('HTMLEvents');
customEvent.initEvent('input', true, true, this.options.contentWindow);
for (i = 0; i < this.elements.length; i += 1) {
this.elements[i].dispatchEvent(customEvent);
// Fire input event for backwards compatibility if anyone was listening directly to the DOM input event
if (this.options.targetBlank || opts.target === '_blank' || opts.buttonClass) {
customEvent = this.options.ownerDocument.createEvent('HTMLEvents');
customEvent.initEvent('input', true, true, this.options.contentWindow);
for (i = 0; i < this.elements.length; i += 1) {
this.elements[i].dispatchEvent(customEvent);
}
}
} finally {
this.events.enableCustomEvent('editableInput');
}
// Fire our custom editableInput event
this.events.triggerCustomEvent('editableInput', customEvent, currentEditor);
},

cleanPaste: function (text) {
Expand Down
13 changes: 12 additions & 1 deletion src/js/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ var Events;
this.base = instance;
this.options = this.base.options;
this.events = [];
this.disabledEvents = {};
this.customEvents = {};
this.listeners = {};
};
Expand Down Expand Up @@ -51,6 +52,16 @@ var Events;
}
},

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);
Expand Down Expand Up @@ -82,7 +93,7 @@ var Events;
},

triggerCustomEvent: function (name, data, editable) {
if (this.customEvents[name]) {
if (this.customEvents[name] && !this.disabledEvents[name]) {
this.customEvents[name].forEach(function (listener) {
listener(data, editable);
});
Expand Down

0 comments on commit 44fa918

Please sign in to comment.