NormalizedPosition.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. 'use strict';
  2. /**
  3. * Licensed Materials - Property of IBM
  4. * IBM Cognos Products: BI Cloud (C) Copyright IBM Corp. 2015, 2017
  5. * US Government Users Restricted Rights - Use, duplication or disclosure restricted by GSA ADP Schedule Contract with IBM Corp.
  6. */
  7. define(['../../../../lib/@waca/core-client/js/core-client/ui/core/Class', 'jquery', 'underscore'], function (Class, $, _) {
  8. //Constants representing whether a cursor is on the leading boundary of a node, inside it, or on the trailing boundary.
  9. var POSITIONS = { BEFORE: 0, INSIDE: 1, AFTER: 2 };
  10. //DOM NODE types used in this file.
  11. var NODE_TYPES = { ELEMENT: 1, TEXT: 3 };
  12. /*
  13. * A cursor position visually is where the text cursor (i.e. carot) appears.
  14. * This is represented in the document selection/range model in a somewhat ambiguous way: a (node, offset) pair. The ambiguity in this approach is that
  15. * when the cursor is on the boundary of nodes, which node is used in that (node, offset) pair? The answer changes based on which browser and platform you
  16. * are using. The NormalizedPosition class attempts to uniquely represent a cursor position by normalizing it.
  17. */
  18. var NormalizedPosition = Class.extend({
  19. /*
  20. * Create a NormalizedPosition.
  21. * @param {node} container - the DOM node of the position (usually corresponds to a range.startContainer or range.endContainer)
  22. * @param {number} offset - the offset into the DOM node (usually corresponds to a range.startOffset or range.endOffset). When offset is against a
  23. * text node, this number represents before which character in the text node the cursor appears; in an element node, it represents before which
  24. * child node the cursor appears
  25. */
  26. init: function init(container, offset) {
  27. this.container = container;
  28. this.offset = offset;
  29. this._normalize();
  30. },
  31. /*
  32. * Normalizes the (container, offset) pair set in the constructor.
  33. * In general: attempts to use the last leaf node which the position represents.
  34. * For example, consider this DOM snippet:
  35. *
  36. * <div>
  37. * <p>
  38. * Hello, World!
  39. * </p>
  40. * <p>
  41. * </p>
  42. * </div>
  43. *
  44. * This snippet will result in the following DOM structure:
  45. *
  46. * 1 Element node: div
  47. * 2 Text node: \n\t
  48. * 3 Element node: p
  49. * 4 Text node: \n\t
  50. * 5 Text node: Hello, World!
  51. * 6 Text node \n
  52. * 7 Text node: \n\t
  53. * 8 Element node: p
  54. * 9 Text node: \n
  55. * 10 Text node: \n
  56. *
  57. * If the text "Hello, World!" is selected, the start node could be any of: 1, 2, 3, 4 or 5 (offset 0) and the end node could be any of: 5 (offset 13),
  58. * 6, 7, 8 (offset 0). The "normalized" position will choose the last leaf which refers to a given position, so the normalized start is node 5, offset 0
  59. * and the normalized end is node 8, offset 0.
  60. *
  61. * Note that nodes 2, 4, 6, 7, 9 and 10 don't render anything. They are considered "trivial".
  62. */
  63. _normalize: function _normalize() {
  64. var container = null;
  65. //Populate the this.position value
  66. this._setCursorPosition();
  67. if (this.position === POSITIONS.BEFORE) {
  68. //Find most precise container (i.e. a leaf node)
  69. this.container = this._findDeepestDescendant(this.container);
  70. //Find closest non-trivial container (i.e. has content user can see)
  71. //(This may already be this.container)
  72. container = this._ensureNodeIsNontrivial(this.container);
  73. //Confirm a non-trivial container was found
  74. if (!container) {
  75. //At end of document, so need to revert to AFTER
  76. container = this._findNontrivialNode(this.container, true);
  77. this.position = POSITIONS.AFTER;
  78. }
  79. this.container = container;
  80. } else if (this.position === POSITIONS.INSIDE) {
  81. if (this.container.nodeType === NODE_TYPES.ELEMENT) {
  82. //Container is an element node, move selection to child node indicated by offset
  83. this.container = this._findDeepestDescendant(this.container.childNodes[this.offset]);
  84. this.position = POSITIONS.BEFORE;
  85. } //else: container is a text node and is already considered 'normal'
  86. } else {
  87. /*i.e. this.position === POSITIONS.AFTER*/
  88. //Find most precise container (i.e. a leaf node)
  89. this.container = this._findDeepestDescendant(this.container, true);
  90. //Find closest non-trivial container (i.e. has content user can see)
  91. //(This should be the next container, since we're trying to switch AFTER to BEFORE)
  92. container = this._findNontrivialNode(this.container);
  93. //Confirm a subsequent container was found and switch position
  94. if (container) {
  95. this.position = POSITIONS.BEFORE;
  96. } else {
  97. //At end of document, so need to keep AFTER
  98. container = this._ensureNodeIsNontrivial(this.container, true);
  99. }
  100. this.container = container;
  101. }
  102. //Update the this.offset value
  103. this._updateOffset();
  104. },
  105. /*
  106. * Initializes this.position from this.offset and this.container
  107. */
  108. _setCursorPosition: function _setCursorPosition() {
  109. if (this.container.nodeType === NODE_TYPES.TEXT) {
  110. if (this.offset === 0) {
  111. this.position = POSITIONS.BEFORE;
  112. } else if (this.offset === this.container.data.length) {
  113. this.position = POSITIONS.AFTER;
  114. } else {
  115. this.position = POSITIONS.INSIDE;
  116. }
  117. } else {
  118. //i.e. this.container.nodeType === NODE_TYPES.ELEMENT
  119. if (this.offset === 0) {
  120. this.position = POSITIONS.BEFORE;
  121. } else if (this.container.childNodes.length && this.offset === this.container.childNodes.length) {
  122. this.position = POSITIONS.AFTER;
  123. } else if (!this.container.childNodes.length && this.offset === 1) {
  124. this.position = POSITIONS.AFTER;
  125. } else {
  126. this.position = POSITIONS.INSIDE;
  127. }
  128. }
  129. },
  130. /*
  131. * Updates this.offset from this.position and this.container
  132. */
  133. _updateOffset: function _updateOffset() {
  134. if (this.position === POSITIONS.BEFORE) {
  135. this.offset = 0;
  136. } else if (this.position === POSITIONS.AFTER) {
  137. this.offset = this.container.nodeType === NODE_TYPES.TEXT ? this.container.data.length : this.container.childNodes.length;
  138. } //else: this.position === POSITIONS.INSIDE, don't need to update offset
  139. },
  140. /*
  141. * @param {node} n
  142. * @param {boolean} [isLast=false] - indicates whether to find the first-child ancestor of 'n' (false) or the last-child ancestor of 'n' (true)
  143. * @return {node} which is a leaf and either the very first descendant of 'n' ('isLast'=false) or the very last descendant of 'n' ('isLast'=true)
  144. */
  145. _findDeepestDescendant: function _findDeepestDescendant(n, isLast) {
  146. while (n.firstChild) {
  147. n = isLast ? n.lastChild : n.firstChild;
  148. }
  149. return n;
  150. },
  151. /*
  152. * @param {node} n
  153. * @return {boolean} true iff 'n' is a blank text node, i.e. the browser will collapse it into the visible text so it doesn't participate in cursor
  154. * positioning
  155. */
  156. _isTrivialNode: function _isTrivialNode(n) {
  157. return n && n.nodeType === NODE_TYPES.TEXT /*text node*/ && n.data.trim() === '' /*no text*/;
  158. },
  159. /*
  160. * This utility function's purpose is to gather all the nodes which will be collapsed into a single cursor-point by the browser.
  161. * @param {node} n - the node to start at; will be included in the returned list
  162. * @param {boolean} [isPrev=false] - a flag to control the direction of the search: forward (false) or backward (true)
  163. * @return {object[]} a list of objects, with members 'container' and 'offset', which all represent equivalent points when selecting. Note that these
  164. * are only nodes in a specific direction (forward or backward) so there may be more equivalent ones in the other directions if 'n' is trivial.
  165. */
  166. _collectEquivalentNodes: function _collectEquivalentNodes(n, isPrev) {
  167. var nodeList = [];
  168. function list(container, beginningOrEnd) {
  169. nodeList.push({
  170. container: container,
  171. offset: beginningOrEnd ? container.nodeType === NODE_TYPES.TEXT ? container.data.length : container.childNodes.length : 0
  172. });
  173. }
  174. //Include parameter n as first entry in list
  175. list(n, !isPrev);
  176. var m = null;
  177. while (!m && n) {
  178. //Step to next sibling
  179. m = isPrev ? n.previousSibling : n.nextSibling;
  180. if (m) {
  181. //There is a sibling:
  182. //If there are children, drill down to a leaf
  183. if (m.childNodes.length) {
  184. m = this._findDeepestDescendant(m, isPrev);
  185. }
  186. //If this node is trivial, continue looping
  187. if (this._isTrivialNode(m)) {
  188. n = m;
  189. m = null;
  190. //Include this as one of the trivial leaf nodes in the list
  191. list(n, false);
  192. } //else: will exit loop
  193. } else {
  194. //There is no sibling: walk the parent chain (so as to then look at their siblings)
  195. n = n.parentNode;
  196. }
  197. }
  198. //Include non-trivial element which ends the list; or null if end of document reached
  199. list(n ? m : null, isPrev);
  200. return nodeList;
  201. },
  202. /*
  203. * @param {node} n
  204. * @param {boolean} [isPrev=false] - indicate whether to look forward (false) or backward (true) in the document
  205. * @return {node} the next (or, if 'isPrev'=true, previous) non-trivial node after (or before) 'n'
  206. */
  207. _findNontrivialNode: function _findNontrivialNode(n, isPrev) {
  208. return this._collectEquivalentNodes(n, isPrev).pop().container;
  209. },
  210. /*
  211. * @param {node} n
  212. * @param {boolean} [isPrev=false]
  213. * @return {node} 'n' if it is non-trivial, otherwise same behaviour as '_findNontrivialNode'
  214. */
  215. _ensureNodeIsNontrivial: function _ensureNodeIsNontrivial(n, isPrev) {
  216. return this._isTrivialNode(n) ? this._findNontrivialNode(n, isPrev) : n;
  217. },
  218. /**
  219. * @param {NormalizedPosition} position
  220. * @return {boolean} true iff 'position' refers to the same position as this
  221. */
  222. equals: function equals(position) {
  223. return this.container === position.container && this.offset === position.offset;
  224. },
  225. /**
  226. * Finds an object representing a valid (not necessarily normalized) range position reference which is equivalent to this, which satisfies the provided
  227. * parameters.
  228. * @param {boolean} favourEarlier - whether to return a position reference which is earlier in the document (true) or later (false)
  229. * @param {string} [selector] - a jQuery selector, describing the desired node
  230. * @return {object} an object with 'container' and 'offset' members, or null if no position was found matching the given selector
  231. */
  232. chooseDocumentPosition: function chooseDocumentPosition(favourEarlier, selector) {
  233. selector = selector || '*';
  234. var nodes = [];
  235. if (this.position === POSITIONS.INSIDE) {
  236. //When the normalized position is inside, there is only ever one node with the equivalent position
  237. nodes.push(this);
  238. } else if (this.position === POSITIONS.BEFORE) {
  239. //Get the nodes starting with the normalized one, then work backward
  240. nodes = this._collectEquivalentNodes(this.container, true);
  241. if (favourEarlier) {
  242. nodes.reverse();
  243. }
  244. } else {
  245. //(This case only occurs when there is no non-trivial node after this one in the document)
  246. //Get the nodes starting with the normalized one, then work forward
  247. nodes = this._collectEquivalentNodes(this.container, false);
  248. nodes.splice(nodes.length - 1, 1); //The last is expected to be null; remove it
  249. if (!favourEarlier) {
  250. nodes.reverse();
  251. }
  252. }
  253. //Find the first node matching the provided selector
  254. return _.find(nodes, function (n) {
  255. var node = n.container;
  256. if (node.nodeType === NODE_TYPES.TEXT) {
  257. node = node.parentNode; //Text nodes never match jQuery expressions, so get the parent
  258. }
  259. return $(node).is(selector);
  260. });
  261. }
  262. });
  263. return NormalizedPosition;
  264. });
  265. //# sourceMappingURL=NormalizedPosition.js.map