// An implementation of (most of) the Text Fragments draft spec.
// See https://wicg.github.io/scroll-to-text-fragment/
// Based on the version of 22 October 2020. <https://raw.githubusercontent.com/WICG/scroll-to-text-fragment/1c05e62b77f8f141d567dd287a2a42ea74870552/index.html>
// Based on the version of 25 November 2020. <https://raw.githubusercontent.com/WICG/scroll-to-text-fragment/91e2a621a8690302f32ee5f4a18517b8c75c5495/index.html>
// “The fragment directive delimiter is the string ":~:", that is the three consecutive code points U+003A (:), U+007E (~), U+003A (:).”
export const fragmentDirectiveDelimiter = ':~:';
// The function below implements most of the specified amendment to the ‘create and initialize a Document object’ steps. It applies the newly introduced steps on an ‘unmodified’ document. Instead of actually setting the document’s URL and fragment directive, it returns the values they should have obtained.
// XXX Should the new procedure really “replace steps 7 and 8”? Which version of the HTML spec was this written for? In the version of 6 August 2020, steps 4, 5 and 9 seem more related.
// We mock the document’s URL and the document’s fragment directive using plain variables.
// As far as I can tell, we cannot access the document’s URL directly — only this serialised version (see <https://dom.spec.whatwg.org/#dom-document-url> as of 29 June 2020).
// “To process and consume fragment directive from a URL url and Document document, run these steps:”
// 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.
// “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.”
let documentFragmentDirective: string | null = null;
let documentFragmentDirective: asciiString | null = null;
// 7. “Let url be null”
let url: string | null = null;
// XXX What is the idea of the new steps 8 and 9? These could at least use an explanatory note:
// 8. “If request is non-null, then set document’s URL to request’s current URL.”
// XXX should this perhaps be “url” instead of “document’s URL”? Otherwise we ignore the fragment directive completely.
// 9. “Otherwise, set url to response’s URL.”
// XXX should be “navigationParams's response”? Also, note its URL could be null.
// In any case, we deviate from the spec in these steps, to allow testing this implementation without access to the request and response. We just take the document’s URL instead.
url = documentUrl;
// 10. “Let raw fragment be equal to url’s fragment.”
// 1. “Let raw fragment be equal to url’s fragment.”
// (as we only have access to the serialised URL, we extract the fragment again)
const rawFragment = url.split('#')[1] ?? null;
// 11. “If raw fragment is non-null:”
if (rawFragment !== null) {
// 2. “If raw fragment is non-null and contains the fragment directive delimiter as a substring:”
if (rawFragment !== null && rawFragment.includes(fragmentDirectiveDelimiter)) {
// 1. “Let fragmentDirectivePosition be the index of the first instance of the fragment directive delimiter in raw fragment.”
let fragmentDirectivePosition = rawFragment.indexOf(fragmentDirectiveDelimiter);
// (a sane implementation would simply use rawFragment.indexOf(…) or rawFragment.split(…) instead the steps below)
// 1. “Let fragmentDirectivePosition be an integer initialized to 0.”
let fragmentDirectivePosition = 0;
// 2. “Let fragment be the substring of raw fragment starting at 0 of count fragmentDirectivePosition.”
const fragment = rawFragment.substring(0, 0 + fragmentDirectivePosition);
// 2. “While the substring of raw fragment starting at position fragmentDirectivePosition does not begin with the fragment directive delimiter and fragmentDirectivePosition does not point past the end of raw fragment:”
// “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.”
// “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.”
// “The text fragment directive is one such fragment directive that enables specifying a piece of text on the page, that matches the production:”
export type TextDirective = string; // could be `unique string`, when (if) TypeScript will support that.
export type TextDirective = asciiString; // should conform to the text directive grammar
export function isTextFragmentDirective(input: string): input is TextDirective {
// TODO (use PEG.js?)
return input.startsWith('text='); // TEMP
@@ -223,7 +200,7 @@ export function scrollToTheFragment(indicatedPart: [Element, Range | null]): voi
// 4. (new) “If range is non-null:”
if (range !== null) {
// 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".”
// “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.”
// “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".”
export function scrollDomRectIntoView(boundingBox: DOMRect, options: ScrollIntoViewOptions, startingElement: Element): void {
// “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:”
// “Replace steps 3-14 of the scroll an element into view algorithm with a call to scroll a DOMRect into view:”
// (note the recursive behaviour is already removed due to the lines above)
// Basing on the <https://drafts.csswg.org/cssom-view-1/#scroll-an-element-into-view> version of 20 February 2020
// “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:”
// 1. (from original) “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.”
// 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.”
// TODO (if this makes sense here at all?)
// 2. (from original) “Let element bounding border box be the box that the return value of invoking getBoundingClientRect() on element represents.”
// 2. “Let element bounding border box be the box that the return value of invoking getBoundingClientRect() on element represents.”
// “Define a new algorithm scroll a Range into view, with input range range, element containingElement, and a ScrollIntoViewOptions dictionary options:”
export function scrollRangeIntoView(range: Range, containingElement: Element, options: ScrollIntoViewOptions): void {
// “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:”
// 3. “Let ranges be a list of ranges, initially empty.”
const ranges = [];
// 4. “For each string directive of directives:”
// 4. “For each ASCII string directive of directives:”
for (const directive of directives) {
// 1. “If directive does not match the production TextDirective, then continue.”
if (!isTextFragmentDirective(directive))
@@ -799,6 +780,7 @@ export function nearestBlockAncestorOf(node: Node): Node {
// “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:”
export function findARangeFromANodeList(queryString: string, searchRange: Range, nodes: Text[], wordStartBounded: boolean, wordEndBounded: boolean): Range | null {
// 1. “Let searchBuffer be the concatenation of the data of each item in nodes.”
// “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>”
// This implementation assumes the browser has already performed the normal procedures to identify and scroll to the fragment, without support for Text Fragments.
// We could change the location to hide the fragment directive from the fragment, as the spec prescribes; however this would also hide it from the user (and could trigger other event listeners).