rangy-highlighter.js 26 KB

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