Skip to content

Commit 7b6d073

Browse files
committed
Merge pull request #87 from upfrontIO/spellcheck-fix
Fix spellcheck whitespace handling
2 parents ffba992 + ac60f29 commit 7b6d073

File tree

10 files changed

+326
-59
lines changed

10 files changed

+326
-59
lines changed

Changelog.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
# v0.4.2
2+
3+
#### Features
4+
5+
- Remove highlights at cursor on corrections [#88](https://github.com/upfrontIO/editable.js/pull/88)
6+
7+
#### Bugfixes
8+
9+
- Fix spellcheck whitespace handling [#87](https://github.com/upfrontIO/editable.js/pull/87)
10+
11+
112
# v0.4.1
213

314
#### Features

editable.js

Lines changed: 90 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4128,6 +4128,7 @@ var content = (function() {
41284128

41294129
var zeroWidthSpace = /\u200B/g;
41304130
var zeroWidthNonBreakingSpace = /\uFEFF/g;
4131+
var whitespaceExceptSpace = /[^\S ]/g;
41314132

41324133
return {
41334134

@@ -4183,6 +4184,10 @@ var content = (function() {
41834184
element.appendChild(fragment);
41844185
},
41854186

4187+
normalizeWhitespace: function(text) {
4188+
return text.replace(whitespaceExceptSpace, ' ');
4189+
},
4190+
41864191
/**
41874192
* Clean the element from character, tags, etc... added by the plugin logic.
41884193
*
@@ -5585,13 +5590,29 @@ var highlightText = (function() {
55855590

55865591
return {
55875592
extractText: function(element) {
5588-
var textNode;
55895593
var text = '';
5594+
this.getText(element, function(part) {
5595+
text += part;
5596+
});
5597+
return text;
5598+
},
5599+
5600+
// Extract the text of an element.
5601+
// This has two notable behaviours:
5602+
// - It uses a NodeIterator which will skip elements
5603+
// with data-editable="remove"
5604+
// - It returns a space for <br> elements
5605+
// (The only block level element allowed inside of editables)
5606+
getText: function(element, callback) {
55905607
var iterator = new NodeIterator(element);
5591-
while ( (textNode = iterator.getNextTextNode()) ) {
5592-
text = text + textNode.data;
5608+
var next;
5609+
while ( (next = iterator.getNext()) ) {
5610+
if (next.nodeType === nodeType.textNode && next.data !== '') {
5611+
callback(next.data);
5612+
} else if (next.nodeType === nodeType.elementNode && next.nodeName === 'BR') {
5613+
callback(' ');
5614+
}
55935615
}
5594-
return text;
55955616
},
55965617

55975618
highlight: function(element, regex, stencilElement) {
@@ -5616,21 +5637,33 @@ var highlightText = (function() {
56165637
return;
56175638
}
56185639

5619-
var textNode, length, offset, isFirstPortion, isLastPortion;
5640+
var next, textNode, length, offset, isFirstPortion, isLastPortion, wordId;
56205641
var currentMatchIndex = 0;
56215642
var currentMatch = matches[currentMatchIndex];
56225643
var totalOffset = 0;
56235644
var iterator = new NodeIterator(element);
56245645
var portions = [];
5625-
while ( (textNode = iterator.getNextTextNode()) ) {
5646+
while ( (next = iterator.getNext()) ) {
5647+
5648+
// Account for <br> elements
5649+
if (next.nodeType === nodeType.textNode && next.data !== '') {
5650+
textNode = next;
5651+
} else if (next.nodeType === nodeType.elementNode && next.nodeName === 'BR') {
5652+
totalOffset = totalOffset + 1;
5653+
continue;
5654+
} else {
5655+
continue;
5656+
}
5657+
56265658
var nodeText = textNode.data;
56275659
var nodeEndOffset = totalOffset + nodeText.length;
5628-
if (nodeEndOffset > currentMatch.startIndex && totalOffset < currentMatch.endIndex) {
5660+
if (currentMatch.startIndex < nodeEndOffset && totalOffset < currentMatch.endIndex) {
56295661

5630-
// get portion position
5662+
// get portion position (fist, last or in the middle)
56315663
isFirstPortion = isLastPortion = false;
56325664
if (totalOffset <= currentMatch.startIndex) {
56335665
isFirstPortion = true;
5666+
wordId = currentMatch.startIndex;
56345667
}
56355668
if (nodeEndOffset >= currentMatch.endIndex) {
56365669
isLastPortion = true;
@@ -5646,16 +5679,17 @@ var highlightText = (function() {
56465679
if (isLastPortion) {
56475680
length = (currentMatch.endIndex - totalOffset) - offset;
56485681
} else {
5649-
length = textNode.data.length - offset;
5682+
length = nodeText.length - offset;
56505683
}
56515684

56525685
// create portion object
56535686
var portion = {
56545687
element: textNode,
5655-
text: textNode.data.substring(offset, offset + length),
5688+
text: nodeText.substring(offset, offset + length),
56565689
offset: offset,
56575690
length: length,
5658-
isLastPortion: isLastPortion
5691+
isLastPortion: isLastPortion,
5692+
wordId: wordId
56595693
};
56605694

56615695
portions.push(portion);
@@ -5701,6 +5735,7 @@ var highlightText = (function() {
57015735
range.setStart(portion.element, portion.offset);
57025736
range.setEnd(portion.element, portion.offset + portion.length);
57035737
var node = stencilElement.cloneNode(true);
5738+
node.setAttribute('data-word-id', portion.wordId);
57045739
range.surroundContents(node);
57055740

57065741
// Fix a weird behaviour where an empty text node is inserted after the range
@@ -6776,11 +6811,12 @@ var Spellcheck = (function() {
67766811
*/
67776812
var Spellcheck = function(editable, configuration) {
67786813
var defaultConfig = {
6779-
checkOnChange: true,
6780-
checkOnFocus: false,
6781-
spellcheckService: undefined,
6782-
markerNode: $('<span class="spellcheck"></span>')[0],
6783-
throttle: 1000 // delay after changes stop before calling the spellcheck service
6814+
checkOnFocus: false, // check on focus
6815+
checkOnChange: true, // check after changes
6816+
throttle: 1000, // unbounce rate in ms before calling the spellcheck service after changes
6817+
removeOnCorrection: true, // remove highlights after a change if the cursor is inside a highlight
6818+
markerNode: $('<span class="spellcheck"></span>'),
6819+
spellcheckService: undefined
67846820
};
67856821

67866822
this.config = $.extend(defaultConfig, configuration);
@@ -6794,7 +6830,7 @@ var Spellcheck = (function() {
67946830
this.editable.on('focus', $.proxy(this, 'onFocus'));
67956831
this.editable.on('blur', $.proxy(this, 'onBlur'));
67966832
}
6797-
if (this.config.checkOnChange) {
6833+
if (this.config.checkOnChange || this.config.removeOnCorrection) {
67986834
this.editable.on('change', $.proxy(this, 'onChange'));
67996835
}
68006836
};
@@ -6813,7 +6849,12 @@ var Spellcheck = (function() {
68136849
};
68146850

68156851
Spellcheck.prototype.onChange = function(editableHost) {
6816-
this.editableHasChanged(editableHost);
6852+
if (this.config.checkOnChange) {
6853+
this.editableHasChanged(editableHost, this.config.throttle);
6854+
}
6855+
if (this.config.removeOnCorrection) {
6856+
this.removeHighlightsAtCursor(editableHost);
6857+
}
68176858
};
68186859

68196860
Spellcheck.prototype.prepareMarkerNode = function() {
@@ -6835,6 +6876,33 @@ var Spellcheck = (function() {
68356876
});
68366877
};
68376878

6879+
Spellcheck.prototype.removeHighlightsAtCursor = function(editableHost) {
6880+
var wordId;
6881+
var selection = this.editable.getSelection(editableHost);
6882+
if (selection && selection.isCursor) {
6883+
var elementAtCursor = selection.range.startContainer;
6884+
if (elementAtCursor.nodeType === nodeType.textNode) {
6885+
elementAtCursor = elementAtCursor.parentNode;
6886+
}
6887+
6888+
do {
6889+
if (elementAtCursor === editableHost) return;
6890+
if ( elementAtCursor.hasAttribute('data-word-id') ) {
6891+
wordId = elementAtCursor.getAttribute('data-word-id');
6892+
break;
6893+
}
6894+
} while ( (elementAtCursor = elementAtCursor.parentNode) );
6895+
6896+
if (wordId) {
6897+
selection.retainVisibleSelection(function() {
6898+
$(editableHost).find('[data-word-id='+ wordId +']').each(function(index, elem) {
6899+
content.unwrap(elem);
6900+
});
6901+
});
6902+
}
6903+
}
6904+
};
6905+
68386906
Spellcheck.prototype.createRegex = function(words) {
68396907
var escapedWords = $.map(words, function(word) {
68406908
return escapeRegEx(word);
@@ -6861,7 +6929,7 @@ var Spellcheck = (function() {
68616929
}
68626930
};
68636931

6864-
Spellcheck.prototype.editableHasChanged = function(editableHost) {
6932+
Spellcheck.prototype.editableHasChanged = function(editableHost, throttle) {
68656933
if (this.timeoutId && this.currentEditableHost === editableHost) {
68666934
clearTimeout(this.timeoutId);
68676935
}
@@ -6871,14 +6939,16 @@ var Spellcheck = (function() {
68716939
that.checkSpelling(editableHost);
68726940
that.currentEditableHost = undefined;
68736941
that.timeoutId = undefined;
6874-
}, this.config.throttle);
6942+
}, throttle || 0);
68756943

68766944
this.currentEditableHost = editableHost;
68776945
};
68786946

68796947
Spellcheck.prototype.checkSpelling = function(editableHost) {
68806948
var that = this;
68816949
var text = highlightText.extractText(editableHost);
6950+
text = content.normalizeWhitespace(text);
6951+
68826952
this.config.spellcheckService(text, function(misspelledWords) {
68836953
var selection = that.editable.getSelection(editableHost);
68846954
if (selection) {

editable.min.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/content.spec.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,24 @@ describe('Content', function() {
5252
});
5353
});
5454

55+
describe('normalizeWhitespace()', function() {
56+
57+
beforeEach(function() {
58+
this.element = $('<div></div>')[0];
59+
});
60+
61+
it('replaces whitespace with spaces', function() {
62+
this.element.innerHTML = '&nbsp; \ufeff';
63+
var text = this.element.textContent;
64+
65+
// Check that textContent works as expected
66+
expect(text).toEqual('\u00A0 \ufeff');
67+
68+
text = content.normalizeWhitespace(text);
69+
expect(text).toEqual(' '); // Check for three spaces
70+
});
71+
72+
});
5573

5674
describe('getInnerTags()', function() {
5775

0 commit comments

Comments
 (0)