text-fragments-ts/src/ index.ts
998 lines
50 KiB

  1. //////////////////////
  2. /// Text Fragments ///
  3. //////////////////////
  4. // An implementation of (most of) the Text Fragments draft spec.
  5. // See https://wicg.github.io/scroll-to-text-fragment/
  6. // Based on the version of 25 November 2020. <https://raw.githubusercontent.com/WICG/scroll-to-text-fragment/91e2a621a8690302f32ee5f4a18517b8c75c5495/index.html>
  7. import {
  8. locale,
  9. isElement,
  10. nextNode,
  11. } from './common.js';
  12. import {
  13. nodeLength,
  14. nextNodeInShadowIncludingTreeOrder,
  15. isShadowIncludingDescendant,
  16. isShadowIncludingInclusiveAncestor,
  17. substringData,
  18. BoundaryPoint,
  19. } from './whatwg-dom.js';
  20. import {
  21. languageOf,
  22. serializesAsVoid,
  23. isBeingRendered,
  24. } from './whatwg-html.js';
  25. import {
  26. asciiString,
  27. htmlNamespace,
  28. } from './whatwg-infra.js';
  29. type nonEmptyString = string;
  30. type integer = number;
  31. // § 3.3.1. Processing the fragment directive
  32. // https://wicg.github.io/scroll-to-text-fragment/#fragment-directive-delimiter
  33. // “The fragment directive delimiter is the string ":~:", that is the three consecutive code points U+003A (:), U+007E (~), U+003A (:).”
  34. export const fragmentDirectiveDelimiter = ':~:';
  35. // https://wicg.github.io/scroll-to-text-fragment/#process-and-consume-fragment-directive
  36. // “To process and consume fragment directive from a URL url and Document document, run these steps:”
  37. // Instead of actually modifying the document’s URL fragment and fragment directive, this implementation returns the values these should have been set to. It therefore does not take the second argument. Also it expects to receive the URL as a string instead of as a URL object.
  38. export function processAndConsumeFragmentDirective(url: string): { url: string, documentFragmentDirective: string | null } {
  39. // “Each document has an associated fragment directive which is either null or an ASCII string holding data used by the UA to process the resource. It is initially null.”
  40. let documentFragmentDirective: asciiString | null = null;
  41. // 1. “Let raw fragment be equal to url’s fragment.”
  42. // (as we only have access to the serialised URL, we extract the fragment again)
  43. const rawFragment = url.split('#')[1] ?? null;
  44. // 2. “If raw fragment is non-null and contains the fragment directive delimiter as a substring:”
  45. if (rawFragment !== null && rawFragment.includes(fragmentDirectiveDelimiter)) {
  46. // 1. “Let fragmentDirectivePosition be the index of the first instance of the fragment directive delimiter in raw fragment.”
  47. let fragmentDirectivePosition = rawFragment.indexOf(fragmentDirectiveDelimiter);
  48. // 2. “Let fragment be the substring of raw fragment starting at 0 of count fragmentDirectivePosition.”
  49. const fragment = rawFragment.substring(0, 0 + fragmentDirectivePosition);
  50. // 3. “Advance fragmentDirectivePosition by the length of fragment directive delimiter.”
  51. fragmentDirectivePosition += fragmentDirectiveDelimiter.length;
  52. // 4. “Let fragment directive be the substring of raw fragment starting at fragmentDirectivePosition.”
  53. const fragmentDirective = rawFragment.substring(fragmentDirectivePosition);
  54. // 5. “Set url’s fragment to fragment.”
  55. // (as we only have access to the serialised URL, we manually replace its fragment part)
  56. url = url.split('#')[0] + (fragment !== null) ? '#' + fragment : '';
  57. // 6. “Set document’s fragment directive to fragment directive.”
  58. documentFragmentDirective = fragmentDirective;
  59. }
  60. // For testing/trying purposes, we return what should now be the document’s URL and fragment directive.
  61. return { url, documentFragmentDirective };
  62. }
  63. // § 3.3.2. Parsing the fragment directive
  64. // https://wicg.github.io/scroll-to-text-fragment/#parsedtextdirective
  65. // “A ParsedTextDirective is a struct that consists of four strings: textStart, textEnd, prefix, and suffix. textStart is required to be non-null. The other three items may be set to null, indicating they weren’t provided. The empty string is not a valid value for any of these items.”
  66. export interface ParsedTextDirective {
  67. textStart: nonEmptyString;
  68. textEnd: nonEmptyString | null;
  69. prefix: nonEmptyString | null;
  70. suffix: nonEmptyString | null;
  71. };
  72. // https://wicg.github.io/scroll-to-text-fragment/#parse-a-text-directive
  73. // “To parse a text directive, on an ASCII string text directive input, run these steps:”
  74. export function parseTextDirective(textDirectiveInput: TextDirective): ParsedTextDirective | null {
  75. // 1. “Assert: text directive input matches the production TextDirective.”
  76. // assert(isTextFragmentDirective(textDirectiveInput));
  77. // 2. “Let textDirectiveString be the substring of text directive input starting at index 5.”
  78. const textDirectiveString = textDirectiveInput.substring(5);
  79. // 3. “Let tokens be a list of strings that is the result of splitting textDirectiveString on commas.”
  80. const tokens = textDirectiveString.split(',');
  81. // 4. “If tokens has size less than 1 or greater than 4, return null.”
  82. if (tokens.length < 1 || tokens.length > 4)
  83. return null;
  84. // 5. “If any of tokens’s items are the empty string, return null.”
  85. if (tokens.some(token => token === ''))
  86. return null;
  87. // 6. “Let retVal be a ParsedTextDirective with each of its items initialized to null.”
  88. const retVal: Partial<ParsedTextDirective> = {
  89. // XXX Initialising textStart to null would conflict with the type definition; hence using Partial<…> instead. Is this temporary type mismatch acceptable in the spec?
  90. textEnd: null,
  91. prefix: null,
  92. suffix: null,
  93. };
  94. // 7. “Let potential prefix be the first item of tokens.”
  95. const potentialPrefix = tokens[0];
  96. // 8. “If the last character of potential prefix is U+002D (-), then:”
  97. if (potentialPrefix.endsWith('-')) {
  98. // 1. “Set retVal’s prefix to the result of removing the last character from potential prefix.
  99. retVal.prefix = decodeURIComponent(potentialPrefix.substring(0, potentialPrefix.length - 1));
  100. // 2. “Remove the first item of the list tokens.”
  101. tokens.shift();
  102. }
  103. // 9. “Let potential suffix be the last item of tokens, if one exists, null otherwise.”
  104. const potentialSuffix = tokens[tokens.length - 1] ?? null;
  105. // 10. “If potential suffix is non-null and its first character is U+002D (-), then:”
  106. if (potentialSuffix !== null && potentialSuffix.startsWith('-')) {
  107. // 1. “Set retVal’s suffix to the result of removing the first character from potential suffix.”
  108. retVal.suffix = decodeURIComponent(potentialSuffix.substring(1));
  109. // 2. “Remove the last item of the list tokens.”
  110. tokens.pop();
  111. }
  112. // 11. “If tokens has size not equal to 1 nor 2 then return null.”
  113. if (tokens.length !== 1 && tokens.length !== 2)
  114. return null;
  115. // 12. “Set retVal’s textStart be the first item of tokens.”
  116. retVal.textStart = decodeURIComponent(tokens[0]);
  117. // 13. “If tokens has size 2, then set retVal’s textEnd be the last item of tokens.”
  118. if (tokens.length === 2)
  119. retVal.textEnd = decodeURIComponent(tokens[tokens.length - 1]);
  120. // 14. “Return retVal.”
  121. return retVal as ParsedTextDirective;
  122. }
  123. // § 3.3.3. Fragment directive grammar
  124. // https://wicg.github.io/scroll-to-text-fragment/#valid-fragment-directive
  125. // “A valid fragment directive is a sequence of characters that appears in the fragment directive that matches the production:”
  126. export type ValidFragmentDirective = string; // could be `unique string`, when (if) TypeScript will support that.
  127. export function isValidFragmentDirective(input: string | null): input is ValidFragmentDirective {
  128. // TODO (use PEG.js?)
  129. return true; // TEMP
  130. }
  131. // https://wicg.github.io/scroll-to-text-fragment/#text-fragment-directive
  132. // “The text fragment directive is one such fragment directive that enables specifying a piece of text on the page, that matches the production:”
  133. export type TextDirective = asciiString; // should conform to the text directive grammar
  134. export function isTextFragmentDirective(input: string): input is TextDirective {
  135. // TODO (use PEG.js?)
  136. return input.startsWith('text='); // TEMP
  137. }
  138. // § 3.5. Navigating to a Text Fragment
  139. // https://wicg.github.io/scroll-to-text-fragment/#navigating-to-text-fragment
  140. // This implements the amended version of step 3 of the HTML spec’s “scroll to the fragment” steps: <https://html.spec.whatwg.org/multipage/browsing-the-web.html#scroll-to-the-fragment-identifier>
  141. export function scrollToTheFragment(indicatedPart: [Element, Range | null]): void {
  142. // (note that step 1 and 2 are irrelevant if the indicated part is an Element/Range, which we require here)
  143. // “Replace step 3.1 of the scroll to the fragment algorithm with the following:”
  144. // 1. (new) “Let target, range be the element and range that is the indicated part of the document.”
  145. const [target, range] = indicatedPart;
  146. // 2. (from original) “Set the Document's target element to target.”
  147. // TODO Perhaps we could fake this by applying any stylesheet rules for :target to target?
  148. // “Replace step 3.3 of the scroll to the fragment algorithm with the following:”
  149. // 3. (new) “Get the policy value for force-load-at-top in the Document. If the result is true, abort these steps.”
  150. // TODO (but this would require access to HTTP headers)
  151. // 4. (new) “If range is non-null:”
  152. if (range !== null) {
  153. // 1. “If the UA supports scrolling of text fragments on navigation, invoke Scroll range into view, with range range, containingElement target, behavior set to "auto", block set to "center", and inline set to "nearest".”
  154. scrollRangeIntoView(range, 'auto', 'center', 'nearest', target);
  155. }
  156. // 5. (new) “Otherwise:”
  157. else {
  158. // 1. (equals original step 3.3) “Scroll target into view, with behavior set to "auto", block set to "start", and inline set to "nearest".”
  159. scrollElementIntoView(target, 'auto', 'start', 'nearest');
  160. }
  161. }
  162. // “Add the following steps to the beginning of the processing model for the indicated part of the document:”
  163. // This function only implements the newly introduced steps. To help testing it out, its required inputs have to be passed as arguments, and the resulting indicated part (if any), is returned, along with the list of ranges (if any).
  164. export function indicatedPartOfTheDocument_beginning(
  165. { document, documentFragmentDirective, documentAllowTextFragmentDirective }:
  166. { document: Document, documentFragmentDirective: string | null, documentAllowTextFragmentDirective: boolean }
  167. ): { documentIndicatedPart: [Element, Range] | undefined, ranges?: Range[] } {
  168. let documentIndicatedPart: [Element, Range] | undefined = undefined;
  169. // 1. “Let fragment directive string be the document’s fragment directive.”
  170. const fragmentDirectiveString = documentFragmentDirective;
  171. // 2. “If the document’s allowTextFragmentDirective flag is true then:”
  172. if (documentAllowTextFragmentDirective === true) {
  173. // 1. “Let ranges be a list that is the result of running the process a fragment directive steps with fragment directive string and the document.”
  174. let ranges = processFragmentDirective(fragmentDirectiveString, document);
  175. // 2. “If ranges is non-empty, then:”
  176. if (ranges.length > 0) {
  177. // 1. “Let range be the first item of ranges.”
  178. const range = ranges[0];
  179. // 2. “Let node be the first common ancestor of range’s start node and start node.”
  180. // XXX This looks silly. Was “start node and end node” meant here?
  181. let node = firstCommonAncestor(range.startContainer, range.startContainer);
  182. // 3. “While node is not an element, set node to node’s parent.”
  183. // XXX Could loop forever! Or is it guaranteed that node has an element as ancestor? This may be a valid but fragile assumption.
  184. while (!isElement(node))
  185. node = node.parentNode as Node;
  186. // 4. “The indicated part of the document is node and range; return.”
  187. documentIndicatedPart = [node, range];
  188. // return;
  189. return { documentIndicatedPart, ranges }; // To allow testing it out.
  190. }
  191. }
  192. return { documentIndicatedPart };
  193. }
  194. // https://wicg.github.io/scroll-to-text-fragment/#first-common-ancestor
  195. // “To find the first common ancestor of two nodes nodeA and nodeB, follow these steps:”
  196. export function firstCommonAncestor(nodeA: Node, nodeB: Node): Node | never {
  197. // 1. “Let commonAncestor be nodeA.”
  198. let commonAncestor = nodeA;
  199. // 2. “While commonAncestor is non-null and is not a shadow-including inclusive ancestor of nodeB, let commonAncestor be commonAncestor’s shadow-including parent.”
  200. while (!isShadowIncludingInclusiveAncestor(commonAncestor, /* of */ nodeB))
  201. commonAncestor = shadowIncludingParent(commonAncestor) as Node;
  202. // 3. “Return commonAncestor.”
  203. return commonAncestor;
  204. }
  205. // https://wicg.github.io/scroll-to-text-fragment/#shadow-including-parent
  206. // “To find the shadow-including parent of node follow these steps:”
  207. export function shadowIncludingParent(node: Node): Node | null {
  208. // 1. “If node is a shadow root, return node’s host.”
  209. if (node instanceof ShadowRoot)
  210. return node.host;
  211. // 2. “Otherwise, return node’s parent.”
  212. return node.parentNode;
  213. }
  214. // § 3.5.1. Scroll a DOMRect into view
  215. // https://wicg.github.io/scroll-to-text-fragment/#scroll-a-domrect-into-view
  216. // “Move the scroll an element into view algorithm’s steps 3-14 into a new algorithm scroll a DOMRect into view, with input DOMRect bounding box, ScrollIntoViewOptions dictionary options, and element startingElement.”
  217. // “Also move the recursive behavior described at the top of the scroll an element into view algorithm to the scroll a DOMRect into view algorithm: "run these steps for each ancestor element or viewport of startingElement that establishes a scrolling box scrolling box, in order of innermost to outermost scrolling box".”
  218. // “To scroll a DOMRect into view given a DOMRect bounding box, a scroll behavior behavior, a block flow direction position block, and an inline base direction position inline, and element startingElement, means to run these steps for each ancestor element or viewport of startingElement that establishes a scrolling box scrolling box, in order of innermost to outermost scrolling box:”
  219. export function scrollDomRectIntoView(boundingBox: DOMRect, behavior: ScrollBehavior, block: ScrollLogicalPosition, inline: ScrollLogicalPosition, startingElement: Element): void {
  220. // “OMITTED”
  221. // TODO Create/borrow a complete implementation.
  222. // TEMP assume the window is the only scrolling box, block=vertical and inline=horizontal, …
  223. function applyScrollLogicalPosition({
  224. position,
  225. boundingBoxRelativeEdgeBegin,
  226. boundingBoxRelativeEdgeEnd,
  227. boundingBoxSize,
  228. scrollBoxAbsoluteEdgeBegin,
  229. scrollBoxSize,
  230. }: {
  231. position: ScrollLogicalPosition,
  232. boundingBoxRelativeEdgeBegin: number,
  233. boundingBoxRelativeEdgeEnd: number,
  234. boundingBoxSize: number,
  235. scrollBoxAbsoluteEdgeBegin: number,
  236. scrollBoxSize: number,
  237. }): number | undefined {
  238. const boundingBoxAbsoluteEdgeBegin = scrollBoxAbsoluteEdgeBegin + boundingBoxRelativeEdgeBegin;
  239. const boundingBoxAbsoluteEdgeEnd = boundingBoxAbsoluteEdgeBegin + boundingBoxSize;
  240. boundingBoxRelativeEdgeEnd -= scrollBoxSize; // measure relative to scroll box’s end, not start.
  241. switch (position) {
  242. case 'start':
  243. return boundingBoxAbsoluteEdgeBegin;
  244. case 'end':
  245. return boundingBoxAbsoluteEdgeEnd - scrollBoxSize;
  246. case 'center':
  247. return boundingBoxAbsoluteEdgeBegin + boundingBoxSize / 2 - scrollBoxSize / 2;
  248. case 'nearest':
  249. const fitsInView = boundingBoxSize < scrollBoxSize; // XXX CSSWG spec seems to forget the case in which the sizes are equal. Here we interpret “greater than” as “greater than or equal to”.
  250. if (boundingBoxRelativeEdgeBegin < 0 && boundingBoxRelativeEdgeEnd > 0)
  251. return undefined;
  252. else if (boundingBoxRelativeEdgeBegin < 0 && fitsInView || boundingBoxRelativeEdgeEnd > 0 && !fitsInView)
  253. return boundingBoxAbsoluteEdgeBegin;
  254. else if (boundingBoxRelativeEdgeBegin < 0 && !fitsInView || boundingBoxRelativeEdgeEnd > 0 && fitsInView)
  255. return boundingBoxAbsoluteEdgeEnd - scrollBoxSize;
  256. }
  257. return undefined;
  258. }
  259. const top = applyScrollLogicalPosition({
  260. position: block ?? 'start', // presuming same default as for Element.scrollIntoView
  261. boundingBoxRelativeEdgeBegin: boundingBox.top,
  262. boundingBoxRelativeEdgeEnd: boundingBox.bottom,
  263. scrollBoxAbsoluteEdgeBegin: window.scrollY,
  264. boundingBoxSize: boundingBox.height,
  265. scrollBoxSize: document.documentElement.clientHeight,
  266. });
  267. const left = applyScrollLogicalPosition({
  268. position: inline ?? 'nearest', // presuming same default as for Element.scrollIntoView
  269. boundingBoxRelativeEdgeBegin: boundingBox.left,
  270. boundingBoxRelativeEdgeEnd: boundingBox.right,
  271. boundingBoxSize: boundingBox.width,
  272. scrollBoxAbsoluteEdgeBegin: window.scrollX,
  273. scrollBoxSize: document.documentElement.clientWidth,
  274. });
  275. window.scroll({ top, left, behavior });
  276. }
  277. // “Replace steps 3-14 of the scroll an element into view algorithm with a call to scroll a DOMRect into view:”
  278. // “To scroll an element into view element, with a scroll behavior behavior, a block flow direction position block, and an inline base direction position inline, means to run these steps:”
  279. export function scrollElementIntoView(element: Element, behavior: ScrollBehavior, block: ScrollLogicalPosition, inline: ScrollLogicalPosition) {
  280. // 1. “If the Document associated with element is not same origin with the Document associated with the element or viewport associated with box, terminate these steps.”
  281. // TODO (if this makes sense here at all?)
  282. // 2. “Let element bounding border box be the box that the return value of invoking getBoundingClientRect() on element represents.”
  283. const elementBoundingBorderBox = element.getBoundingClientRect();
  284. // 3. “Perform scroll a DOMRect into view given element bounding border box, options and element.”
  285. // XXX There is no “options” defined; presumably that should be “behavior, block, inline”.
  286. scrollDomRectIntoView(elementBoundingBorderBox, behavior, block, inline, element);
  287. }
  288. // https://wicg.github.io/scroll-to-text-fragment/#scroll-a-range-into-view
  289. // “To scroll a Range into view, with input range range, scroll behavior behavior, a block flow direction position block, an inline base direction position inline, and an element containingElement:”
  290. export function scrollRangeIntoView(range: Range, behavior: ScrollBehavior, block: ScrollLogicalPosition, inline: ScrollLogicalPosition, containingElement: Element): void {
  291. // 1. “Let bounding rect be the DOMRect that is the return value of invoking getBoundingClientRect() on range.”
  292. const boundingRect = range.getBoundingClientRect();
  293. // 2. “Perform scroll a DOMRect into view given bounding rect, behavior, block, inline, and containingElement.”
  294. scrollDomRectIntoView(boundingRect, behavior, block, inline, containingElement);
  295. }
  296. // § 3.5.2 Finding Ranges in a Document
  297. // https://wicg.github.io/scroll-to-text-fragment/#process-a-fragment-directive
  298. // “To process a fragment directive, given as input an ASCII string fragment directive input and a Document document, run these steps:”
  299. export function processFragmentDirective(fragmentDirectiveInput: asciiString | null, document: Document): Range[] {
  300. // 1. “If fragment directive input is not a valid fragment directive, then return an empty list.”
  301. if (!isValidFragmentDirective(fragmentDirectiveInput)) {
  302. return [];
  303. }
  304. // 2. “Let directives be a list of ASCII strings that is the result of strictly splitting the string fragment directive input on "&".”
  305. const directives = fragmentDirectiveInput.split('&');
  306. // 3. “Let ranges be a list of ranges, initially empty.”
  307. const ranges = [];
  308. // 4. “For each ASCII string directive of directives:”
  309. for (const directive of directives) {
  310. // 1. “If directive does not match the production TextDirective, then continue.”
  311. if (!isTextFragmentDirective(directive))
  312. continue;
  313. // 2. “Let parsedValues be the result of running the parse a text directive steps on directive.”
  314. const parsedValues = parseTextDirective(directive);
  315. // 3. “If parsedValues is null then continue.”
  316. if (parsedValues === null)
  317. continue;
  318. // 4. “If the result of running find a range from a text directive given parsedValues and document is non-null, then append it to ranges.”
  319. const range = findRangeFromTextDirective(parsedValues, document);
  320. if (range !== null)
  321. ranges.push(range);
  322. }
  323. // 5. “Return ranges.”
  324. return ranges;
  325. }
  326. // https://wicg.github.io/scroll-to-text-fragment/#find-a-range-from-a-text-directive
  327. // “To find a range from a text directive, given a ParsedTextDirective parsedValues and Document document, run the following steps:”
  328. export function findRangeFromTextDirective(parsedValues: ParsedTextDirective, document: Document): Range | null {
  329. // 1. “Let searchRange be a range with start (document, 0) and end (document, document’s length)”
  330. const searchRange = document.createRange();
  331. searchRange.setStart(document, 0);
  332. searchRange.setEnd(document, document.childNodes.length);
  333. // 2. “While searchRange is not collapsed:”
  334. while (!searchRange.collapsed) {
  335. // 1. “Let potentialMatch be null.”
  336. let potentialMatch = null;
  337. // 2. “If parsedValues’s prefix is not null:”
  338. if (parsedValues.prefix !== null) {
  339. // 1. “Let prefixMatch be the the result of running the find a string in range steps with query parsedValues’s prefix, searchRange searchRange, wordStartBounded true and wordEndBounded false.”
  340. const prefixMatch = findStringInRange(parsedValues.prefix, searchRange, true, false);
  341. // 2. “If prefixMatch is null, return null.”
  342. if (prefixMatch === null)
  343. return null;
  344. // 3. “Set searchRange’s start to the first boundary point after prefixMatch’s start”
  345. // XXX I suppose we can be certain a next boundary point always exist in this case; can we proof this?
  346. searchRange.setStart(...firstBoundaryPointAfter(getStart(prefixMatch)) as BoundaryPoint);
  347. // 4. “Let matchRange be a range whose start is prefixMatch’s end and end is searchRange’s end.”
  348. const matchRange = document.createRange();
  349. matchRange.setStart(...getEnd(prefixMatch));
  350. matchRange.setEnd(...getEnd(searchRange));
  351. // 5. “Advance matchRange’s start to the next non-whitespace position.”
  352. advanceRangeStartToNextNonWhitespacePosition(matchRange);
  353. // 6. “If matchRange is collapsed return null.”
  354. if (matchRange.collapsed)
  355. return null;
  356. // 7. “Assert: matchRange’s start node is a Text node.”
  357. // assert(matchRange.startContainer.nodeType === Node.TEXT_NODE);
  358. // 8. “Let mustEndAtWordBoundary be true if parsedValues’s textEnd is non-null or parsedValues’s suffix is null, false otherwise.”
  359. const mustEndAtWordBoundary = (parsedValues.textEnd !== null || parsedValues.suffix === null);
  360. // 9. “Set potentialMatch to the result of running the find a string in range steps with query parsedValues’s textStart, searchRange matchRange, wordStartBounded false, and wordEndBounded mustEndAtWordBoundary.”
  361. potentialMatch = findStringInRange(parsedValues.textStart, matchRange, false, mustEndAtWordBoundary);
  362. // 10. “If potentialMatch is null, return null.”
  363. if (potentialMatch === null)
  364. return null;
  365. // 11. “If potentialMatch’s start is not matchRange’s start, then continue.”
  366. if (!samePoint(getStart(potentialMatch), getStart(matchRange)))
  367. continue;
  368. }
  369. // 3. “Otherwise:”
  370. else {
  371. // 1. “Let mustEndAtWordBoundary be true if parsedValues’s textEnd is non-null or parsedValues’s suffix is null, false otherwise.”
  372. const mustEndAtWordBoundary = (parsedValues.textEnd !== null || parsedValues.suffix === null);
  373. // 2. “Set potentialMatch to the result of running the find a string in range steps with query parsedValues’s textStart, searchRange searchRange, wordStartBounded true, and wordEndBounded mustEndAtWordBoundary.”
  374. potentialMatch = findStringInRange(parsedValues.textStart, searchRange, true, mustEndAtWordBoundary);
  375. // 3. “If potentialMatch is null, return null.”
  376. if (potentialMatch === null)
  377. return null;
  378. // 4. “Set searchRange’s start to the first boundary point after potentialMatch’s start”
  379. // XXX I suppose we can be certain a next boundary point always exist in this case; can we proof this?
  380. searchRange.setStart(...firstBoundaryPointAfter(getStart(potentialMatch)) as BoundaryPoint);
  381. }
  382. // 4. “If parsedValues’s textEnd item is non-null, then:”
  383. if (parsedValues.textEnd !== null) {
  384. // 1. “Let textEndRange be a range whose start is potentialMatch’s end and whose end is searchRange’s end.”
  385. const textEndRange = document.createRange();
  386. textEndRange.setStart(...getEnd(potentialMatch));
  387. textEndRange.setEnd(...getEnd(searchRange));
  388. // 2. “Let mustEndAtWordBoundary be true if parsedValues’s suffix is null, false otherwise.”
  389. const mustEndAtWordBoundary = parsedValues.suffix === null;
  390. // 3. “Let textEndMatch be the result of running the find a string in range steps with query parsedValues’s textEnd, searchRange textEndRange, wordStartBounded true, and wordEndBounded mustEndAtWordBoundary.”
  391. const textEndMatch = findStringInRange(parsedValues.textEnd, textEndRange, true, mustEndAtWordBoundary);
  392. // 4. “If textEndMatch is null then return null.”
  393. if (textEndMatch === null)
  394. return null;
  395. // 5. “Set potentialMatch’s end to textEndMatch’s end.”
  396. potentialMatch.setEnd(...getEnd(textEndMatch));
  397. }
  398. // 5. “Assert: potentialMatch is non-null, not collapsed and represents a range exactly containing an instance of matching text.” XXX the last assertion sounds rather vague.
  399. // assert(
  400. // potentialMatch !== null
  401. // && !potentialMatch.collapsed
  402. // && new RegExp('^' + escapeRegExp(textStart) + '.*' + escapeRegExp(textEnd) + '$').test(potentialMatch.toString()) // …or something similar?
  403. // );
  404. // 6. “If parsedValues’s suffix is null, return potentialMatch.”
  405. if (parsedValues.suffix === null)
  406. return potentialMatch;
  407. // 7. “Let suffixRange be a range with start equal to potentialMatch’s end and end equal to searchRange’s end.”
  408. const suffixRange = document.createRange();
  409. suffixRange.setStart(...getEnd(potentialMatch));
  410. suffixRange.setEnd(...getEnd(searchRange));
  411. // 8. “Advance suffixRange’s start to the next non-whitespace position.”
  412. advanceRangeStartToNextNonWhitespacePosition(suffixRange);
  413. // 9. “Let suffixMatch be result of running the find a string in range steps with query parsedValues’s suffix, searchRange suffixRange, wordStartBounded false, and wordEndBounded true.”
  414. const suffixMatch = findStringInRange(parsedValues.suffix, suffixRange, false, true);
  415. // 10. “If suffixMatch is null then return null.”
  416. if (suffixMatch === null)
  417. return null;
  418. // 11. “If suffixMatch’s start is suffixRange’s start, return potentialMatch.”
  419. if (samePoint(getStart(suffixMatch), getStart(suffixRange)))
  420. return potentialMatch;
  421. }
  422. // 3. “Return null”
  423. return null;
  424. }
  425. // https://wicg.github.io/scroll-to-text-fragment/#next-non-whitespace-position
  426. // “To advance a range range’s start to the next non-whitespace position follow the steps:”
  427. export function advanceRangeStartToNextNonWhitespacePosition(range: Range) {
  428. // 1. “While range is not collapsed:”
  429. while (!range.collapsed) {
  430. // 1. “Let node be range’s start node.”
  431. const node = range.startContainer;
  432. // 2. “Let offset be range’s start offset.”
  433. const offset = range.startOffset;
  434. // 3. “If node is part of a non-searchable subtree then:”
  435. if (partOfNonSearchableSubtree(node)) {
  436. // 1. “Set range’s start node to the next node, in shadow-including tree order, that isn’t a shadow-including descendant of node, and set its start offset to 0.”
  437. range.setStart(
  438. nextNodeInShadowIncludingTreeOrderThatIsNotAShadowIncludingDescendantOf(node) as Node, // XXX Can we be sure there is a next node? Asserting it here.
  439. 0,
  440. );
  441. // 2. “Continue.”
  442. continue;
  443. }
  444. // 4. “If node is not a visible text node:”
  445. if (!isVisibleTextNode(node)) {
  446. // 1. “Set range’s start node to the next node, in shadow-including tree order, and set its start offset to 0.”
  447. range.setStart(
  448. nextNodeInShadowIncludingTreeOrder(node) as Node, // XXX Can we be sure there is a next node? Asserting it here.
  449. 0,
  450. );
  451. // 2. “Continue.”
  452. continue;
  453. }
  454. // 5. “If the substring data of node at offset offset and count 6 is equal to the string "&nbsp;" then:” XXX Why only "&nbsp;", and not e.g. "&thinspace;" or others? Is there no existing spec for whitespace that can be used here?
  455. if (substringData(node as CharacterData, offset, 6) === '&nbsp;') { // XXX Is node guaranteed to be CharacterData? Not clear in spec.
  456. // 1. “Add 6 to range’s start offset.”
  457. range.setStart(range.startContainer, range.startOffset + 6);
  458. }
  459. // 6. “Otherwise, if the substring data of node at offset offset and count 5 is equal to the string "&nbsp" then:”
  460. else if (substringData(node as CharacterData, offset, 5) === '&nbsp') { // XXX Is node guaranteed to be CharacterData? Not clear in spec.
  461. // 1. “Add 5 to range’s start offset.”
  462. range.setStart(range.startContainer, range.startOffset + 5);
  463. }
  464. // 7. “Otherwise”
  465. else {
  466. // 1. “Let cp be the code point at the offset index in node’s data.”
  467. const cp = (node as CharacterData).data.codePointAt(offset) as number; // TODO verify if this is correct. We use the index to count code *units*, but we read the code *point*, which smells fishy but may be correct.
  468. // 2. “If cp does not have the White_Space property set, return.”
  469. if (!hasWhiteSpaceProperty(cp)) return;
  470. // 3. “Add 1 to range’s start offset.”
  471. range.setStart(range.startContainer, range.startOffset + 1);
  472. }
  473. // 8. “If range’s start offset is equal to node’s length, set range’s start node to the next node in shadow-including tree order, and set its start offset to 0.”
  474. if (range.startOffset === nodeLength(node)) {
  475. range.setStart(
  476. nextNodeInShadowIncludingTreeOrder(node) as Node, // XXX Can we be sure there is a next node? Asserting it here.
  477. 0,
  478. );
  479. }
  480. }
  481. }
  482. // https://wicg.github.io/scroll-to-text-fragment/#find-a-string-in-range
  483. // To find a string in range given a string query, a range searchRange, and booleans wordStartBounded and wordEndBounded, run these steps:
  484. export function findStringInRange(query: string, searchRange: Range, wordStartBounded: boolean, wordEndBounded: boolean): Range | null {
  485. // 1. “While searchRange is not collapsed:”
  486. while (!searchRange.collapsed) {
  487. // 1. “Let curNode be searchRange’s start node.”
  488. let curNode: Node | null = searchRange.startContainer;
  489. // 2. “If curNode is part of a non-searchable subtree:”
  490. if (partOfNonSearchableSubtree(curNode)) {
  491. // 1. “Set searchRange’s start node to the next node, in shadow-including tree order, that isn’t a shadow-including descendant of curNode”
  492. searchRange.setStart(
  493. nextNodeInShadowIncludingTreeOrderThatIsNotAShadowIncludingDescendantOf(curNode) as Node, // XXX Can we be sure there is a next node? Asserting it here.
  494. 0, // XXX presumably we should set the offset to zero?
  495. );
  496. // 2. “Continue.”
  497. continue;
  498. }
  499. // 3. “If curNode is not a visible text node:”
  500. if (!isVisibleTextNode(curNode)) {
  501. // 1. “Set searchRange’s start node to the next node, in shadow-including tree order, that is not a doctype, and set its start offset to 0.”
  502. curNode = nextNodeInShadowIncludingTreeOrder(curNode);
  503. while (curNode && curNode.nodeType === Node.DOCUMENT_TYPE_NODE)
  504. curNode = nextNodeInShadowIncludingTreeOrder(curNode);
  505. searchRange.setStart(
  506. curNode as Node, // XXX Can we be sure there is a next node? Asserting it here.
  507. 0,
  508. );
  509. // 2. “Continue.”
  510. continue;
  511. }
  512. // 4. “Let blockAncestor be the nearest block ancestor of curNode.”
  513. const blockAncestor = nearestBlockAncestorOf(curNode);
  514. // 5. “Let textNodeList be a list of Text nodes, initially empty.”
  515. const textNodeList: Text[] = [];
  516. // 6. “While curNode is a shadow-including descendant of blockAncestor and the position of the boundary point (curNode, 0) is not after searchRange’s end:”
  517. while (
  518. curNode && isShadowIncludingDescendant(curNode, /* of */ blockAncestor)
  519. && searchRange.comparePoint(curNode, 0) !== 1
  520. ) {
  521. // 1. “If curNode has block-level display then break.”
  522. if (hasBlockLevelDisplay(curNode)) {
  523. break;
  524. }
  525. // 2. “If curNode is search invisible:”
  526. if (isSearchInvisible(curNode)) {
  527. // 1. “Set curNode to the next node in shadow-including tree order that isn’t a shadow-including descendant of curNode.”
  528. curNode = nextNodeInShadowIncludingTreeOrderThatIsNotAShadowIncludingDescendantOf(curNode);
  529. // 2. “Continue.”
  530. continue;
  531. }
  532. // 3. “If curNode is a visible text node then append it to textNodeList.”
  533. if (isVisibleTextNode(curNode)) {
  534. textNodeList.push(curNode);
  535. }
  536. // 4. “Set curNode to the next node in shadow-including tree order.”
  537. curNode = nextNodeInShadowIncludingTreeOrder(curNode);
  538. }
  539. // 7. “Run the find a range from a node list steps given query, searchRange, textNodeList, wordStartBounded and wordEndBounded as input. If the resulting range is not null, then return it.”
  540. const resultingRange = findARangeFromANodeList(query, searchRange, textNodeList, wordStartBounded, wordEndBounded);
  541. if (resultingRange !== null) {
  542. return resultingRange;
  543. }
  544. // 8. “If curNode is null, then break.”
  545. if (curNode === null)
  546. break;
  547. // 9. “Assert: curNode follows searchRange’s start node.”
  548. // assert(searchRange.startContainer.compareDocumentPosition(curNode) & Node.DOCUMENT_POSITION_FOLLOWING);
  549. // 10. “Set searchRange’s start to the boundary point (curNode, 0).”
  550. searchRange.setStart(curNode, 0);
  551. }
  552. // 2. “Return null.”
  553. return null;
  554. }
  555. // https://wicg.github.io/scroll-to-text-fragment/#search-invisible
  556. // “A node is search invisible…”
  557. export function isSearchInvisible(node: Node): boolean {
  558. // “…if it is an element in the HTML namespace and meets any of the following conditions:”
  559. if (isElement(node) && node.namespaceURI === htmlNamespace) {
  560. // 1. “The computed value of its display property is none.”
  561. if (getComputedStyle(node).display === 'none')
  562. return true;
  563. // 2. “If the node serializes as void.”
  564. if (serializesAsVoid(node))
  565. return true;
  566. // 3. “Is any of the following types: HTMLIFrameElement, HTMLImageElement, HTMLMeterElement, HTMLObjectElement, HTMLProgressElement, HTMLStyleElement, HTMLScriptElement, HTMLVideoElement, HTMLAudioElement”
  567. if (['iframe', 'image', 'meter', 'object', 'progress', 'style', 'script', 'video', 'audio'].includes(node.localName)) // TODO verify: is this correct? Do class names and localName map one-to-one? (hopefully yes, as the term ‘element type’ seems used for both concepts)
  568. return true;
  569. // 4. “Is a select element whose multiple content attribute is absent.”
  570. if (node.localName === 'select' && !node.hasAttribute('multiple'))
  571. return true;
  572. }
  573. return false;
  574. }
  575. // https://wicg.github.io/scroll-to-text-fragment/#non-searchable-subtree
  576. // “A node is part of a non-searchable subtree if it is or has a shadow-including ancestor that is search invisible.”
  577. export function partOfNonSearchableSubtree(node: Node): boolean {
  578. let curNode: Node | null = node;
  579. while (curNode) {
  580. if (isSearchInvisible(curNode))
  581. return true;
  582. curNode = shadowIncludingParent(curNode);
  583. }
  584. return false;
  585. }
  586. // https://wicg.github.io/scroll-to-text-fragment/#visible-text-node
  587. // “A node is a visible text node if it is a Text node, the computed value of its parent element's visibility property is visible, and it is being rendered.”
  588. export type VisibleTextNode = Text; // could be `unique Text`, when (if) TypeScript will support that.
  589. export function isVisibleTextNode(node: Node): node is VisibleTextNode {
  590. return (
  591. node.nodeType === Node.TEXT_NODE
  592. && node.parentElement !== null
  593. && getComputedStyle(node.parentElement).visibility === 'visible'
  594. && isBeingRendered(node.parentElement)
  595. );
  596. }
  597. // https://wicg.github.io/scroll-to-text-fragment/#has-block-level-display
  598. // “A node has block-level display if it is an element and the computed value of its display property is any of block, table, flow-root, grid, flex, list-item.”
  599. export function hasBlockLevelDisplay(node: Node): boolean {
  600. return (
  601. isElement(node)
  602. && ['block', 'table', 'flow-root', 'grid', 'flex', 'list-item'].includes(getComputedStyle(node).display)
  603. );
  604. }
  605. // https://wicg.github.io/scroll-to-text-fragment/#nearest-block-ancestor
  606. // “To find the nearest block ancestor of a node follow the steps:”
  607. export function nearestBlockAncestorOf(node: Node): Node {
  608. // 1. “Let curNode be node.”
  609. let curNode: Node | null = node;
  610. // 2. “While curNode is non-null”
  611. while (curNode !== null) {
  612. // 1. “If curNode is not a Text node and it has block-level display then return curNode.”
  613. if (curNode.nodeType !== Node.TEXT_NODE && hasBlockLevelDisplay(curNode))
  614. return curNode;
  615. // 2. “Otherwise, set curNode to curNode’s parent.”
  616. else
  617. curNode = curNode.parentNode;
  618. }
  619. // 3. “Return node’s node document's document element.”
  620. return (node.ownerDocument ?? node as Document).documentElement;
  621. }
  622. // https://wicg.github.io/scroll-to-text-fragment/#find-a-range-from-a-node-list
  623. // “To find a range from a node list given a search string queryString, a range searchRange, a list of Text nodes nodes, and booleans wordStartBounded and wordEndBounded, follow these steps:”
  624. export function findARangeFromANodeList(queryString: string, searchRange: Range, nodes: Text[], wordStartBounded: boolean, wordEndBounded: boolean): Range | null {
  625. // 1. “Let searchBuffer be the concatenation of the data of each item in nodes.”
  626. // “ISSUE 1 data is not correct here since that’s the text data as it exists in the DOM. This algorithm means to run over the text as rendered (and then convert back to Ranges in the DOM). <https://github.com/WICG/scroll-to-text-fragment/issues/98>”
  627. const searchBuffer = nodes.map(node => node.data).join('');
  628. // 2. “Let searchStart be 0.”
  629. let searchStart = 0;
  630. // 3. “If the first item in nodes is searchRange’s start node then set searchStart to searchRange’s start offset.”
  631. if (nodes[0] === searchRange.startContainer)
  632. searchStart = searchRange.startOffset;
  633. // 4. “Let start and end be boundary points, initially null.”
  634. let start: BoundaryPoint | null = null;
  635. let end: BoundaryPoint | null = null;
  636. // 5. “Let matchIndex be null.”
  637. let matchIndex = null;
  638. // 6. “While matchIndex is null”
  639. while (matchIndex === null) {
  640. // 1. “Set matchIndex to the index of the first instance of queryString in searchBuffer, starting at searchStart. The string search must be performed using a base character comparison, or the primary level, as defined in [UTS10].”
  641. // TODO implement base character comparison (i.e. ignoring accents, etc.)
  642. // XXX It would be helpful to have more specific guidance than merely a link to UTS10 <https://www.unicode.org/reports/tr10/tr10-43.html>
  643. matchIndex = searchBuffer.toLowerCase().indexOf(queryString.toLowerCase(), searchStart); // TEMP case-insensitive string match will have to suffice for now.
  644. // 2. “If matchIndex is null, return null.”
  645. if (matchIndex === -1)
  646. return null;
  647. // 3. “Let endIx be matchIndex + queryString’s length.”
  648. const endIx = matchIndex + queryString.length;
  649. // 4. “Set start to the boundary point result of get boundary point at index matchIndex run over nodes with isEnd false.”
  650. start = getBoundaryPointAtIndex(matchIndex, nodes, false) as BoundaryPoint;
  651. // 5. “Set end to the boundary point result of get boundary point at index endIx run over nodes with isEnd true.”
  652. end = getBoundaryPointAtIndex(endIx, nodes, true) as BoundaryPoint;
  653. // XXX Assert start and end are non-null? (should be correct, as matchIndex and endIx are both less than the search text’s length)
  654. // 6. “If wordStartBounded is true and matchIndex is not at a word boundary in searchBuffer, given the language from start’s node as the locale; or wordEndBounded is true and matchIndex + queryString’s length is not at a word boundary in searchBuffer, given the language from end’s node as the locale:”
  655. if (
  656. wordStartBounded && !isAtWordBoundary(matchIndex, searchBuffer, languageOf(start[0]))
  657. || wordEndBounded && !isAtWordBoundary(matchIndex + queryString.length, searchBuffer, languageOf(end[0]))
  658. ) {
  659. // 1. “Set searchStart to matchIndex + 1.”
  660. searchStart = matchIndex + 1;
  661. // 2. “Set matchIndex to null.”
  662. matchIndex = null;
  663. }
  664. }
  665. // 7. “Let endInset be 0.”
  666. let endInset = 0;
  667. // 8. “If the last item in nodes is searchRange’s end node then set endInset to (searchRange’s end node's length − searchRange’s end offset)”
  668. if (nodes[nodes.length - 1] === searchRange.endContainer)
  669. endInset = (searchRange.endContainer as Text).length - searchRange.endOffset;
  670. // 9. “If matchIndex + queryString’s length is greater than searchBuffer’s length − endInset return null.”
  671. if (matchIndex + queryString.length > searchBuffer.length - endInset)
  672. return null;
  673. // 10. “Assert: start and end are non-null, valid boundary points in searchRange.”
  674. // assert(start !== null && end !== null && searchRange.comparePoint(...start) === 0 && searchRange.comparePoint(...end) === 0);
  675. start = start as BoundaryPoint;
  676. end = end as BoundaryPoint;
  677. // 11. “Return a range with start start and end end.”
  678. const result = document.createRange();
  679. result.setStart(...start);
  680. result.setEnd(...end);
  681. return result;
  682. }
  683. // https://wicg.github.io/scroll-to-text-fragment/#get-boundary-point-at-index
  684. // “To get boundary point at index, given an integer index, list of Text nodes nodes, and a boolean isEnd, follow these steps:”
  685. export function getBoundaryPointAtIndex(index: integer, nodes: Text[], isEnd: boolean): BoundaryPoint | null {
  686. // 1. “Let counted be 0.”
  687. let counted = 0;
  688. // 2. “For each curNode of nodes:”
  689. for (const curNode of nodes) {
  690. // 1. “Let nodeEnd be counted + curNode’s length.”
  691. let nodeEnd = counted + curNode.length;
  692. // 2. “If isEnd is true, add 1 to nodeEnd.”
  693. if (isEnd)
  694. nodeEnd += 1;
  695. // 3. “If nodeEnd is greater than index then:”
  696. if (nodeEnd > index) {
  697. // 1. “Return the boundary point (curNode, index − counted).”
  698. return [curNode, index - counted];
  699. }
  700. // 4. “Increment counted by curNode’s length.”
  701. counted += curNode.length;
  702. }
  703. // 3. “Return null.”
  704. return null;
  705. }
  706. // § 3.5.3 Word Boundaries
  707. // https://wicg.github.io/scroll-to-text-fragment/#word-boundary:
  708. // “A word boundary is defined in [UAX29] in Unicode Text Segmentation §Word_Boundaries. Unicode Text Segmentation §Default_Word_Boundaries defines a default set of what constitutes a word boundary, but as the specification mentions, a more sophisticated algorithm should be used based on the locale.”
  709. // https://wicg.github.io/scroll-to-text-fragment/#locale
  710. // “A locale is a string containing a valid [BCP47] language tag, or the empty string. An empty string indicates that the primary language is unknown.”
  711. // (the locale type is defined in ./common.ts and imported above)
  712. // https://wicg.github.io/scroll-to-text-fragment/#is-at-a-word-boundary
  713. // “A number position is at a word boundary in a string text, given a locale locale, if, using locale, …”
  714. export function isAtWordBoundary(position: number, text: string, locale: locale) {
  715. // “…either a word boundary immediately precedes the positionth code unit, …”
  716. // TODO Implement the “default word boundary specification” of the referenced unicode spec.
  717. // TEMP Just use a regular expression to test against a pair of alphanumeric characters.
  718. if (text.charAt(position) && text.substring(position - 1, position + 1).match(/^[\w\d]{2,2}$/) === null)
  719. return true;
  720. // “…or text’s length is more than 0 and position equals either 0 or text’s length.”
  721. if (text.length > 0 && (position === 0 || position === text.length))
  722. return true;
  723. return false;
  724. }
  725. // https://wicg.github.io/scroll-to-text-fragment/#feature-detectability
  726. // § 3.8. Feature Detectability
  727. // “For feature detectability, we propose adding a new FragmentDirective interface that is exposed via document.fragmentDirective if the UA supports the feature.
  728. // [Exposed=Document]
  729. // interface FragmentDirective {
  730. // };
  731. // We amend the Document interface to include a fragmentDirective property:
  732. // partial interface Document {
  733. // [SameObject] readonly attribute FragmentDirective fragmentDirective;
  734. // };”
  735. export interface FragmentDirective {
  736. };
  737. // TODO Can and should we modify the Document interface?
  738. export function browserSupportsTextFragments(): boolean {
  739. return (
  740. 'fragmentDirective' in Document
  741. // Also check in window.location, which was in the spec until & including the version of 12 August 2020. See commit <https://github.com/WICG/scroll-to-text-fragment/commit/2dcfbd6e272f51e5b250c58076b6d1cc57656fce>.
  742. || 'fragmentDirective' in window.location
  743. );
  744. }
  745. //////////////////////////////////////
  746. /// Simple helpers for readability ///
  747. //////////////////////////////////////
  748. function getStart(range: Range): BoundaryPoint {
  749. return [range.startContainer, range.startOffset];
  750. }
  751. function getEnd(range: Range): BoundaryPoint {
  752. return [range.endContainer, range.endOffset];
  753. }
  754. function samePoint(point1: BoundaryPoint, point2: BoundaryPoint): boolean {
  755. return point1[0] === point2[0] && point1[1] === point2[1];
  756. }
  757. function nextNodeInShadowIncludingTreeOrderThatIsNotAShadowIncludingDescendantOf(node: Node): Node | null {
  758. let curNode: Node | null = nextNodeInShadowIncludingTreeOrder(node);
  759. while (curNode && isShadowIncludingDescendant(curNode, node)) {
  760. curNode = nextNodeInShadowIncludingTreeOrder(curNode);
  761. }
  762. return curNode;
  763. }
  764. ///////////
  765. // Other //
  766. ///////////
  767. function hasWhiteSpaceProperty(codePoint: number): boolean {
  768. // Soon to be widely supported in browsers. <https://caniuse.com/#feat=mdn-javascript_builtins_regexp_property_escapes>
  769. // return !!String.fromCodePoint(codePoint).match(/\p{White_Space}/u);
  770. // The list below takes the values from <https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt> version of 2019-11-27
  771. const whitespaceCodePoints = [
  772. 0x0009, 0x000A, 0x000B, 0x000C, 0x000D,
  773. 0x0085, 0x2028, 0x2029, 0x0020, 0x3000,
  774. 0x1680, 0x2000, 0x2001, 0x2002, 0x2003,
  775. 0x2004, 0x2005, 0x2006, 0x2008, 0x2009,
  776. 0x200A, 0x205F, 0x00A0, 0x2007, 0x202F,
  777. ];
  778. return whitespaceCodePoints.includes(codePoint);
  779. }
  780. // XXX Is this supposed to be self-evident, or should these steps perhaps be included in the spec?
  781. function firstBoundaryPointAfter([node, offset]: BoundaryPoint): BoundaryPoint | null {
  782. if (offset < nodeLength(node)) { // (note that N children/characters makes for N+1 boundary points)
  783. return [node, offset + 1];
  784. } else {
  785. const next = nextNode(node);
  786. if (next !== null)
  787. return [next, 0];
  788. else
  789. return null;
  790. }
  791. }