Skip to content

Commit 41e19c5

Browse files
committed
Add highlight-text util
1 parent 6841de7 commit 41e19c5

File tree

2 files changed

+305
-0
lines changed

2 files changed

+305
-0
lines changed

spec/highlight-text.spec.js

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
describe('highlightText', function() {
2+
3+
// Helper Methods
4+
// --------------
5+
6+
var createParagraphWithTextNodes = function(firstPart, parts) {
7+
var textNode, part;
8+
var elem = $('<p>'+ firstPart +'</p>')[0];
9+
for (var i=1; i<arguments.length; i++) {
10+
part = arguments[i];
11+
textNode = document.createTextNode(part);
12+
elem.appendChild(textNode);
13+
}
14+
return elem;
15+
};
16+
17+
var iterateOverElement = function(elem, regex) {
18+
var matches = highlightText.find(elem, regex);
19+
var range = highlightText.getRange(elem);
20+
highlightText.iterate(range, matches);
21+
};
22+
23+
24+
describe('minimal case', function() {
25+
26+
beforeEach(function() {
27+
this.textNode = $('<div>a</div>')[0];
28+
this.regex = /a/g;
29+
});
30+
31+
it('extracts the text', function(){
32+
var range = highlightText.getRange(this.textNode);
33+
var text = highlightText.extractText(range);
34+
expect(text).toEqual('a');
35+
});
36+
37+
it('finds the letter "a"', function() {
38+
var matches = highlightText.find(this.textNode, this.regex);
39+
var firstMatch = matches[0];
40+
expect(firstMatch.search).toEqual('a');
41+
expect(firstMatch.matchIndex).toEqual(0);
42+
expect(firstMatch.startIndex).toEqual(0);
43+
expect(firstMatch.endIndex).toEqual(1);
44+
});
45+
46+
it('does not find the letter "b"', function() {
47+
var matches = highlightText.find(this.textNode, /b/g);
48+
expect(matches.length).toEqual(0);
49+
});
50+
});
51+
52+
describe('Some juice.', function() {
53+
54+
beforeEach(function() {
55+
this.textNode = $('<div>Some juice.</div>')[0];
56+
this.regex = /juice/g;
57+
});
58+
59+
it('finds the word "juice"', function() {
60+
var matches = highlightText.find(this.textNode, this.regex);
61+
var firstMatch = matches[0];
62+
expect(firstMatch.search).toEqual('juice');
63+
expect(firstMatch.matchIndex).toEqual(0);
64+
expect(firstMatch.startIndex).toEqual(5);
65+
expect(firstMatch.endIndex).toEqual(10);
66+
});
67+
68+
});
69+
70+
describe('iterator', function() {
71+
72+
beforeEach(function() {
73+
this.wrapWord = sinon.stub(highlightText, 'wrapWord');
74+
});
75+
76+
afterEach(function() {
77+
this.wrapWord.restore();
78+
});
79+
80+
it('finds a letter that it is own text node', function() {
81+
var elem = createParagraphWithTextNodes('a', 'b', 'c');
82+
iterateOverElement(elem, /b/g);
83+
var portions = this.wrapWord.firstCall.args[0];
84+
85+
expect(portions.length).toEqual(1);
86+
expect(portions[0].text).toEqual('b');
87+
expect(portions[0].offset).toEqual(0);
88+
expect(portions[0].length).toEqual(1);
89+
expect(portions[0].lastPortion).toEqual(true);
90+
});
91+
92+
it('finds a letter that is in a text node with a letter before', function() {
93+
var elem = createParagraphWithTextNodes('a', 'xb', 'c');
94+
iterateOverElement(elem, /b/g);
95+
var portions = this.wrapWord.firstCall.args[0];
96+
97+
expect(portions.length).toEqual(1);
98+
expect(portions[0].text).toEqual('b');
99+
expect(portions[0].offset).toEqual(1);
100+
expect(portions[0].length).toEqual(1);
101+
expect(portions[0].lastPortion).toEqual(true);
102+
});
103+
104+
it('finds a letter that is in a text node with a letter after', function() {
105+
var elem = createParagraphWithTextNodes('a', 'bx', 'c');
106+
iterateOverElement(elem, /b/g);
107+
var portions = this.wrapWord.firstCall.args[0];
108+
109+
expect(portions.length).toEqual(1);
110+
expect(portions[0].text).toEqual('b');
111+
expect(portions[0].offset).toEqual(0);
112+
expect(portions[0].length).toEqual(1);
113+
expect(portions[0].lastPortion).toEqual(true);
114+
});
115+
116+
it('finds two letters that span over two text nodes', function() {
117+
var elem = createParagraphWithTextNodes('a', 'b', 'c');
118+
iterateOverElement(elem, /bc/g);
119+
var portions = this.wrapWord.firstCall.args[0];
120+
121+
expect(portions.length).toEqual(2);
122+
expect(portions[0].text).toEqual('b');
123+
expect(portions[0].lastPortion).toEqual(false);
124+
125+
expect(portions[1].text).toEqual('c');
126+
expect(portions[1].lastPortion).toEqual(true);
127+
});
128+
129+
it('finds three letters that span over three text nodes', function() {
130+
var elem = createParagraphWithTextNodes('a', 'b', 'c');
131+
iterateOverElement(elem, /abc/g);
132+
var portions = this.wrapWord.firstCall.args[0];
133+
134+
expect(portions.length).toEqual(3);
135+
expect(portions[0].text).toEqual('a');
136+
expect(portions[1].text).toEqual('b');
137+
expect(portions[2].text).toEqual('c');
138+
});
139+
140+
it('finds a word that is partially contained in two text nodes', function() {
141+
var elem = createParagraphWithTextNodes('a', 'bxx', 'xxe');
142+
iterateOverElement(elem, /xxxx/g);
143+
var portions = this.wrapWord.firstCall.args[0];
144+
145+
expect(portions.length).toEqual(2);
146+
expect(portions[0].text).toEqual('xx');
147+
expect(portions[0].offset).toEqual(1);
148+
expect(portions[0].length).toEqual(2);
149+
expect(portions[0].lastPortion).toEqual(false);
150+
151+
expect(portions[1].text).toEqual('xx');
152+
expect(portions[1].offset).toEqual(0);
153+
expect(portions[1].length).toEqual(2);
154+
expect(portions[1].lastPortion).toEqual(true);
155+
});
156+
157+
});
158+
159+
describe('wrapWord', function() {
160+
161+
it('wraps a word in a single text node', function() {
162+
var elem = $('<div>Some juice.</div>')[0];
163+
var matches = highlightText.find(elem, /juice/g);
164+
var range = highlightText.getRange(elem);
165+
highlightText.iterate(range, matches);
166+
expect(range.commonAncestorContainer.outerHTML)
167+
.toEqual('<div>Some <span data-awesome="crazy">juice</span>.</div>')
168+
});
169+
170+
it('wraps a word with a partial <em> element', function() {
171+
var elem = $('<div>Some jui<em>ce</em>.</div>')[0];
172+
var matches = highlightText.find(elem, /juice/g);
173+
var range = highlightText.getRange(elem);
174+
highlightText.iterate(range, matches);
175+
expect(range.commonAncestorContainer.outerHTML)
176+
.toEqual('<div>Some <span data-awesome="crazy">jui</span><em><span data-awesome="crazy">ce</span></em>.</div>')
177+
});
178+
});
179+
180+
});

src/highlight-text.js

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
var highlightText = (function() {
2+
3+
return {
4+
extractText: function(range) {
5+
return range.toString();
6+
},
7+
8+
getRange: function(element) {
9+
var range = rangy.createRange();
10+
range.selectNodeContents(element);
11+
return range;
12+
},
13+
14+
getTextIterator: function(range) {
15+
return range.createNodeIterator([3], function(node) {
16+
// no filter needed right now.
17+
return true;
18+
});
19+
},
20+
21+
iterate: function(range, matches) {
22+
var textNode, length, firstPortion, lastPortion;
23+
var currentMatchIndex = 0;
24+
var currentMatch = matches[currentMatchIndex];
25+
var totalOffset = 0;
26+
var iterator = this.getTextIterator(range);
27+
var portions = [];
28+
while ( (textNode = iterator.next()) ) {
29+
var nodeText = textNode.data;
30+
var nodeEndOffset = totalOffset + nodeText.length;
31+
if (nodeEndOffset > currentMatch.startIndex && totalOffset < currentMatch.endIndex) {
32+
33+
// get portion position
34+
firstPortion = lastPortion = false;
35+
if (totalOffset <= currentMatch.startIndex) {
36+
firstPortion = true;
37+
}
38+
if (nodeEndOffset >= currentMatch.endIndex) {
39+
lastPortion = true;
40+
}
41+
42+
// calculate offset and length
43+
var offset;
44+
if (firstPortion) {
45+
offset = currentMatch.startIndex - totalOffset;
46+
} else {
47+
offset = 0;
48+
}
49+
50+
var length;
51+
if (lastPortion) {
52+
length = (currentMatch.endIndex - totalOffset) - offset;
53+
} else {
54+
length = textNode.data.length - offset;
55+
}
56+
57+
// create portion object
58+
var portion = {
59+
element: textNode,
60+
text: textNode.data.substring(offset, offset + length),
61+
offset: offset,
62+
length: length,
63+
lastPortion: lastPortion
64+
}
65+
66+
portions.push(portion);
67+
68+
if (lastPortion) {
69+
this.wrapWord(portions);
70+
portions = [];
71+
currentMatchIndex += 1;
72+
if (currentMatchIndex < matches.length) {
73+
currentMatch = matches[currentMatchIndex];
74+
}
75+
}
76+
77+
}
78+
79+
totalOffset = nodeEndOffset;
80+
}
81+
},
82+
83+
wrapWord: function(portions) {
84+
var element;
85+
for (var i = 0; i < portions.length; i++) {
86+
var portion = portions[i];
87+
element = this.wrapPortion(portion);
88+
}
89+
90+
return element;
91+
},
92+
93+
wrapPortion: function(portion) {
94+
var range = rangy.createRange();
95+
range.setStart(portion.element, portion.offset);
96+
range.setEnd(portion.element, portion.offset + portion.length);
97+
var node = $('<span data-awesome="crazy">')[0];
98+
return range.surroundContents(node);
99+
},
100+
101+
find: function(element, regex) {
102+
var range = this.getRange(element);
103+
var text = this.extractText(range);
104+
var match;
105+
var matches = [];
106+
var matchIndex = 0;
107+
while (match = regex.exec(text)) {
108+
matches.push(this.prepareMatch(match, matchIndex));
109+
matchIndex += 1;
110+
}
111+
112+
return matches;
113+
},
114+
115+
prepareMatch: function (match, matchIndex) {
116+
return {
117+
startIndex: match.index,
118+
endIndex: match.index + match[0].length,
119+
matchIndex: matchIndex,
120+
search: match[0]
121+
}
122+
}
123+
124+
}
125+
})();

0 commit comments

Comments
 (0)