rangy-highlighter.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. /**
  2. * Highlighter module for Rangy, a cross-browser JavaScript range and selection library
  3. * http://code.google.com/p/rangy/
  4. *
  5. * Depends on Rangy core, TextRange and CssClassApplier modules.
  6. *
  7. * Copyright 2013, Tim Down
  8. * Licensed under the MIT license.
  9. * Version: 1.3alpha.804
  10. * Build date: 8 December 2013
  11. */
  12. rangy.createModule("Highlighter", ["ClassApplier"], function(api, module) {
  13. var dom = api.dom;
  14. var contains = dom.arrayContains;
  15. var getBody = dom.getBody;
  16. // Puts highlights in order, last in document first.
  17. function compareHighlights(h1, h2) {
  18. return h1.characterRange.start - h2.characterRange.start;
  19. }
  20. var forEach = [].forEach ?
  21. function(arr, func) {
  22. arr.forEach(func);
  23. } :
  24. function(arr, func) {
  25. for (var i = 0, len = arr.length; i < len; ++i) {
  26. func( arr[i] );
  27. }
  28. };
  29. var nextHighlightId = 1;
  30. /*----------------------------------------------------------------------------------------------------------------*/
  31. var highlighterTypes = {};
  32. function HighlighterType(type, converterCreator) {
  33. this.type = type;
  34. this.converterCreator = converterCreator;
  35. }
  36. HighlighterType.prototype.create = function() {
  37. var converter = this.converterCreator();
  38. converter.type = this.type;
  39. return converter;
  40. };
  41. function registerHighlighterType(type, converterCreator) {
  42. highlighterTypes[type] = new HighlighterType(type, converterCreator);
  43. }
  44. function getConverter(type) {
  45. var highlighterType = highlighterTypes[type];
  46. if (highlighterType instanceof HighlighterType) {
  47. return highlighterType.create();
  48. } else {
  49. throw new Error("Highlighter type '" + type + "' is not valid");
  50. }
  51. }
  52. api.registerHighlighterType = registerHighlighterType;
  53. /*----------------------------------------------------------------------------------------------------------------*/
  54. function CharacterRange(start, end) {
  55. this.start = start;
  56. this.end = end;
  57. }
  58. CharacterRange.prototype = {
  59. intersects: function(charRange) {
  60. return this.start < charRange.end && this.end > charRange.start;
  61. },
  62. union: function(charRange) {
  63. return new CharacterRange(Math.min(this.start, charRange.start), Math.max(this.end, charRange.end));
  64. },
  65. intersection: function(charRange) {
  66. return new CharacterRange(Math.max(this.start, charRange.start), Math.min(this.end, charRange.end));
  67. },
  68. toString: function() {
  69. return "[CharacterRange(" + this.start + ", " + this.end + ")]";
  70. }
  71. };
  72. CharacterRange.fromCharacterRange = function(charRange) {
  73. return new CharacterRange(charRange.start, charRange.end);
  74. };
  75. /*----------------------------------------------------------------------------------------------------------------*/
  76. var textContentConverter = {
  77. rangeToCharacterRange: function(range, containerNode) {
  78. var bookmark = range.getBookmark(containerNode);
  79. return new CharacterRange(bookmark.start, bookmark.end);
  80. },
  81. characterRangeToRange: function(doc, characterRange, containerNode) {
  82. var range = api.createRange(doc);
  83. range.moveToBookmark({
  84. start: characterRange.start,
  85. end: characterRange.end,
  86. containerNode: containerNode
  87. });
  88. return range;
  89. },
  90. serializeSelection: function(selection, containerNode) {
  91. var ranges = selection.getAllRanges(), rangeCount = ranges.length;
  92. var rangeInfos = [];
  93. var backward = rangeCount == 1 && selection.isBackward();
  94. for (var i = 0, len = ranges.length; i < len; ++i) {
  95. rangeInfos[i] = {
  96. characterRange: this.rangeToCharacterRange(ranges[i], containerNode),
  97. backward: backward
  98. };
  99. }
  100. return rangeInfos;
  101. },
  102. restoreSelection: function(selection, savedSelection, containerNode) {
  103. selection.removeAllRanges();
  104. var doc = selection.win.document;
  105. for (var i = 0, len = savedSelection.length, range, rangeInfo, characterRange; i < len; ++i) {
  106. rangeInfo = savedSelection[i];
  107. characterRange = rangeInfo.characterRange;
  108. range = this.characterRangeToRange(doc, rangeInfo.characterRange, containerNode);
  109. selection.addRange(range, rangeInfo.backward);
  110. }
  111. }
  112. };
  113. registerHighlighterType("textContent", function() {
  114. return textContentConverter;
  115. });
  116. /*----------------------------------------------------------------------------------------------------------------*/
  117. // Lazily load the TextRange-based converter so that the dependency is only checked when required.
  118. registerHighlighterType("TextRange", (function() {
  119. var converter;
  120. return function() {
  121. if (!converter) {
  122. // Test that textRangeModule exists and is supported
  123. var textRangeModule = api.modules.TextRange;
  124. if (!textRangeModule) {
  125. throw new Error("TextRange module is missing.");
  126. } else if (!textRangeModule.supported) {
  127. throw new Error("TextRange module is present but not supported.");
  128. }
  129. converter = {
  130. rangeToCharacterRange: function(range, containerNode) {
  131. return CharacterRange.fromCharacterRange( range.toCharacterRange(containerNode) );
  132. },
  133. characterRangeToRange: function(doc, characterRange, containerNode) {
  134. var range = api.createRange(doc);
  135. range.selectCharacters(containerNode, characterRange.start, characterRange.end);
  136. return range;
  137. },
  138. serializeSelection: function(selection, containerNode) {
  139. return selection.saveCharacterRanges(containerNode);
  140. },
  141. restoreSelection: function(selection, savedSelection, containerNode) {
  142. selection.restoreCharacterRanges(containerNode, savedSelection);
  143. }
  144. };
  145. }
  146. return converter;
  147. };
  148. })());
  149. /*----------------------------------------------------------------------------------------------------------------*/
  150. function Highlight(doc, characterRange, classApplier, converter, id, containerElementId) {
  151. if (id) {
  152. this.id = id;
  153. nextHighlightId = Math.max(nextHighlightId, id + 1);
  154. } else {
  155. this.id = nextHighlightId++;
  156. }
  157. this.characterRange = characterRange;
  158. this.doc = doc;
  159. this.classApplier = classApplier;
  160. this.converter = converter;
  161. this.containerElementId = containerElementId || null;
  162. this.applied = false;
  163. }
  164. Highlight.prototype = {
  165. getContainerElement: function() {
  166. return this.containerElementId ? this.doc.getElementById(this.containerElementId) : getBody(this.doc);
  167. },
  168. getRange: function() {
  169. return this.converter.characterRangeToRange(this.doc, this.characterRange, this.getContainerElement());
  170. },
  171. fromRange: function(range) {
  172. this.characterRange = this.converter.rangeToCharacterRange(range, this.getContainerElement());
  173. },
  174. getText: function() {
  175. return this.getRange().toString();
  176. },
  177. containsElement: function(el) {
  178. return this.getRange().containsNodeContents(el.firstChild);
  179. },
  180. unapply: function() {
  181. this.classApplier.undoToRange(this.getRange());
  182. this.applied = false;
  183. },
  184. apply: function() {
  185. this.classApplier.applyToRange(this.getRange());
  186. this.applied = true;
  187. },
  188. getHighlightElements: function() {
  189. return this.classApplier.getElementsWithClassIntersectingRange(this.getRange());
  190. },
  191. toString: function() {
  192. return "[Highlight(ID: " + this.id + ", class: " + this.classApplier.cssClass + ", character range: " +
  193. this.characterRange.start + " - " + this.characterRange.end + ")]";
  194. }
  195. };
  196. /*----------------------------------------------------------------------------------------------------------------*/
  197. function Highlighter(doc, type) {
  198. type = type || "textContent";
  199. this.doc = doc || document;
  200. this.classAppliers = {};
  201. this.highlights = [];
  202. this.converter = getConverter(type);
  203. }
  204. Highlighter.prototype = {
  205. addClassApplier: function(classApplier) {
  206. this.classAppliers[classApplier.cssClass] = classApplier;
  207. },
  208. getHighlightForElement: function(el) {
  209. var highlights = this.highlights;
  210. for (var i = 0, len = highlights.length; i < len; ++i) {
  211. if (highlights[i].containsElement(el)) {
  212. return highlights[i];
  213. }
  214. }
  215. return null;
  216. },
  217. removeHighlights: function(highlights) {
  218. for (var i = 0, len = this.highlights.length, highlight; i < len; ++i) {
  219. highlight = this.highlights[i];
  220. if (contains(highlights, highlight)) {
  221. highlight.unapply();
  222. this.highlights.splice(i--, 1);
  223. }
  224. }
  225. },
  226. removeAllHighlights: function() {
  227. this.removeHighlights(this.highlights);
  228. },
  229. getIntersectingHighlights: function(ranges) {
  230. // Test each range against each of the highlighted ranges to see whether they overlap
  231. var intersectingHighlights = [], highlights = this.highlights, converter = this.converter;
  232. forEach(ranges, function(range) {
  233. //var selCharRange = converter.rangeToCharacterRange(range);
  234. forEach(highlights, function(highlight) {
  235. if (range.intersectsRange( highlight.getRange() ) && !contains(intersectingHighlights, highlight)) {
  236. intersectingHighlights.push(highlight);
  237. }
  238. });
  239. });
  240. return intersectingHighlights;
  241. },
  242. highlightCharacterRanges: function(className, charRanges, containerElementId) {
  243. var i, len, j;
  244. var highlights = this.highlights;
  245. var converter = this.converter;
  246. var doc = this.doc;
  247. var highlightsToRemove = [];
  248. var classApplier = this.classAppliers[className];
  249. containerElementId = containerElementId || null;
  250. var containerElement, containerElementRange, containerElementCharRange;
  251. if (containerElementId) {
  252. containerElement = this.doc.getElementById(containerElementId);
  253. if (containerElement) {
  254. containerElementRange = api.createRange(this.doc);
  255. containerElementRange.selectNodeContents(containerElement);
  256. containerElementCharRange = new CharacterRange(0, containerElementRange.toString().length);
  257. containerElementRange.detach();
  258. }
  259. }
  260. var charRange, highlightCharRange, merged;
  261. for (i = 0, len = charRanges.length; i < len; ++i) {
  262. charRange = charRanges[i];
  263. merged = false;
  264. // Restrict character range to container element, if it exists
  265. if (containerElementCharRange) {
  266. charRange = charRange.intersection(containerElementCharRange);
  267. }
  268. // Check for intersection with existing highlights. For each intersection, create a new highlight
  269. // which is the union of the highlight range and the selected range
  270. for (j = 0; j < highlights.length; ++j) {
  271. if (containerElementId == highlights[j].containerElementId) {
  272. highlightCharRange = highlights[j].characterRange;
  273. if (highlightCharRange.intersects(charRange)) {
  274. // Replace the existing highlight in the list of current highlights and add it to the list for
  275. // removal
  276. highlightsToRemove.push(highlights[j]);
  277. highlights[j] = new Highlight(doc, highlightCharRange.union(charRange), classApplier, converter, null, containerElementId);
  278. }
  279. }
  280. }
  281. if (!merged) {
  282. highlights.push( new Highlight(doc, charRange, classApplier, converter, null, containerElementId) );
  283. }
  284. }
  285. // Remove the old highlights
  286. forEach(highlightsToRemove, function(highlightToRemove) {
  287. highlightToRemove.unapply();
  288. });
  289. // Apply new highlights
  290. var newHighlights = [];
  291. forEach(highlights, function(highlight) {
  292. if (!highlight.applied) {
  293. highlight.apply();
  294. newHighlights.push(highlight);
  295. }
  296. });
  297. return newHighlights;
  298. },
  299. highlightRanges: function(className, ranges, containerElement) {
  300. var selCharRanges = [];
  301. var converter = this.converter;
  302. var containerElementId = containerElement ? containerElement.id : null;
  303. var containerElementRange;
  304. if (containerElement) {
  305. containerElementRange = api.createRange(containerElement);
  306. containerElementRange.selectNodeContents(containerElement);
  307. }
  308. forEach(ranges, function(range) {
  309. var scopedRange = containerElement ? containerElementRange.intersection(range) : range;
  310. selCharRanges.push( converter.rangeToCharacterRange(scopedRange, containerElement || getBody(range.getDocument())) );
  311. });
  312. return this.highlightCharacterRanges(selCharRanges, ranges, containerElementId);
  313. },
  314. highlightSelection: function(className, selection, containerElementId) {
  315. var converter = this.converter;
  316. selection = selection || api.getSelection();
  317. var classApplier = this.classAppliers[className];
  318. var doc = selection.win.document;
  319. var containerElement = containerElementId ? doc.getElementById(containerElementId) : getBody(doc);
  320. if (!classApplier) {
  321. throw new Error("No class applier found for class '" + className + "'");
  322. }
  323. // Store the existing selection as character ranges
  324. var serializedSelection = converter.serializeSelection(selection, containerElement);
  325. // Create an array of selected character ranges
  326. var selCharRanges = [];
  327. forEach(serializedSelection, function(rangeInfo) {
  328. selCharRanges.push( CharacterRange.fromCharacterRange(rangeInfo.characterRange) );
  329. });
  330. var newHighlights = this.highlightCharacterRanges(className, selCharRanges, containerElementId);
  331. // Restore selection
  332. converter.restoreSelection(selection, serializedSelection, containerElement);
  333. return newHighlights;
  334. },
  335. unhighlightSelection: function(selection) {
  336. selection = selection || api.getSelection();
  337. var intersectingHighlights = this.getIntersectingHighlights( selection.getAllRanges() );
  338. this.removeHighlights(intersectingHighlights);
  339. selection.removeAllRanges();
  340. return intersectingHighlights;
  341. },
  342. getHighlightsInSelection: function(selection) {
  343. selection = selection || api.getSelection();
  344. return this.getIntersectingHighlights(selection.getAllRanges());
  345. },
  346. selectionOverlapsHighlight: function(selection) {
  347. return this.getHighlightsInSelection(selection).length > 0;
  348. },
  349. serialize: function(options) {
  350. var highlights = this.highlights;
  351. highlights.sort(compareHighlights);
  352. var serializedHighlights = ["type:" + this.converter.type];
  353. forEach(highlights, function(highlight) {
  354. var characterRange = highlight.characterRange;
  355. var parts = [
  356. characterRange.start,
  357. characterRange.end,
  358. highlight.id,
  359. highlight.classApplier.cssClass,
  360. highlight.containerElementId
  361. ];
  362. if (options && options.serializeHighlightText) {
  363. parts.push(highlight.getText());
  364. }
  365. serializedHighlights.push( parts.join("$") );
  366. });
  367. return serializedHighlights.join("|");
  368. },
  369. deserialize: function(serialized) {
  370. var serializedHighlights = serialized.split("|");
  371. var highlights = [];
  372. var firstHighlight = serializedHighlights[0];
  373. var regexResult;
  374. var serializationType, serializationConverter, convertType = false;
  375. if ( firstHighlight && (regexResult = /^type:(\w+)$/.exec(firstHighlight)) ) {
  376. serializationType = regexResult[1];
  377. if (serializationType != this.converter.type) {
  378. serializationConverter = getConverter(serializationType);
  379. convertType = true;
  380. }
  381. serializedHighlights.shift();
  382. } else {
  383. throw new Error("Serialized highlights are invalid.");
  384. }
  385. var classApplier, highlight, characterRange, containerElementId, containerElement;
  386. for (var i = serializedHighlights.length, parts; i-- > 0; ) {
  387. parts = serializedHighlights[i].split("$");
  388. characterRange = new CharacterRange(+parts[0], +parts[1]);
  389. containerElementId = parts[4] || null;
  390. containerElement = containerElementId ? this.doc.getElementById(containerElementId) : getBody(this.doc);
  391. // Convert to the current Highlighter's type, if different from the serialization type
  392. if (convertType) {
  393. characterRange = this.converter.rangeToCharacterRange(
  394. serializationConverter.characterRangeToRange(this.doc, characterRange, containerElement),
  395. containerElement
  396. );
  397. }
  398. classApplier = this.classAppliers[parts[3]];
  399. highlight = new Highlight(this.doc, characterRange, classApplier, this.converter, parseInt(parts[2]), containerElementId);
  400. highlight.apply();
  401. highlights.push(highlight);
  402. }
  403. this.highlights = highlights;
  404. }
  405. };
  406. api.Highlighter = Highlighter;
  407. api.createHighlighter = function(doc, rangeCharacterOffsetConverterType) {
  408. return new Highlighter(doc, rangeCharacterOffsetConverterType);
  409. };
  410. });