Agustin Barrientos
All posts

Keeping Text Annotations Attached When a Document Changes

Want more of these in your Google results?

Some writing apps let you leave notes on part of the text. For example, you can comment on a paragraph in Google Docs or add a note to a highlight in Kindle. Tools like Hypothesis let you leave these annotations (a highlight plus its note) on any web page.

Drawing a highlight takes a few lines of code. Keeping it on the words the user picked is a lot harder, because the text under it usually changes a lot.

Say your docs site lets users leave notes. On the page about the build cache, a user highlights the sentence “The cache keeps compiled templates for seven days”. Their note says “Long enough for our weekly release?”

Over the next few months, you release four new versions of that page:

  • In 1.1, you add a paragraph above that sentence.
  • In 1.2, you copy the sentence into a tip in the Quick start section.
  • In 1.3, you change “seven days” to “five days” in the sentence, but forget the tip.
  • In 1.4, you remove the paragraph with that sentence.

Every time the user comes back, the site has to find their sentence again and put the note back on it. If it picks the wrong words, there’s no error. The highlight just shows up on other words, or on the old copy in the tip.

Google’s guide to comments in the Drive API says that if your app attaches a comment to part of a file, there’s no guarantee it stays in the same place after the file changes. Amazon’s help for Kindle publishers says that after a publisher updates a book, Kindle moves readers’ highlights and notes to the new version. Big changes can take a little longer.

I tested four annotation libraries for this article. All four put the user’s note on the wrong words at least once.

For the demos, I created that docs page as version 1.0, with three notes from the same user, including the green one on “The cache keeps compiled templates for seven days”. The Page version menu has version 1.0 and the four releases, 1.1 to 1.4. The site builds that version and puts the notes back from what it saved.

In the first demo, the docs site saves a note the simplest way, as two numbers: where its passage starts and where it ends in the page’s text. Pick “1.1 New paragraph”, the release that adds a paragraph above the green note’s sentence.

Now the green and orange highlights are on other words. Their cards in the margin show the words those highlights are on now. The blue note is above the new paragraph, so it’s still right.

In this article I’ll go through how to save a note so the site can find the right words again after a release, and what to show the user if it can’t.

Drawing the highlight

You’d usually highlight text by wrapping it in a <mark> or a <span>. The issue is that the wrapper splits the Text node (the DOM node that holds just the text) and puts new elements in the middle of the text the site measures to find the notes. Hypothesis wraps text that way, and so does web-highlighter, a library for saving highlights on a page.

With the CSS Custom Highlight API, the browser paints the highlight without changing the DOM at all. All the major browsers support it.

Browser supportCustom Highlight API95% of users
Chrome105
Edge105
Firefox140
Safari17.2
Safari iOS17.2
Chrome Android105

Browser data updated October 22, 2026

You start with a range, the object the DOM uses for a piece of the page between two points. Then you put the range in a Highlight and register it under a name with CSS.highlights.set().

On version 1.0, the green note’s sentence is characters 35 to 85 of the Text node in its paragraph:

highlight.js
// highlight.js
const $text = $paragraph.firstChild;
const range = document.createRange();
range.setStart( $text, 35 );
range.setEnd( $text, 85 );

const highlight = new Highlight( range );
CSS.highlights.set( 'note-green', highlight );

Nothing shows up yet, though. Custom highlights have no style until you give them one with the ::highlight() pseudo-element:

notes.css
/* notes.css */
::highlight( note-green ) {
  background-color: rgb( 0 158 115 / 0.24 );
  text-decoration: underline 2px #009e73;
}

The spec only lets you use a few properties inside ::highlight(), like colors, text decorations, and text shadows.

Saving the position

To keep notes after the user closes the tab, the docs site saves them as JSON, on its server or in the browser’s storage.

You can’t put a range in that JSON, though, as it points at nodes in the page as it is right now. They’re all gone after the next page load.

The simplest thing to save instead is the two numbers from the first demo: where the passage starts and where it ends. On the next load, the site counts the characters again and turns the two numbers back into a range. A lot of first versions of this feature work that way. That’s the first way to save a note, by its position.

To count those characters, the docs site visits all the Text nodes inside the content with a TreeWalker and joins them into one string. It keeps that string in a text index, with a map from each position back to its node and its offset inside that node.

Whitespace counts once per run, as a single space, since even the newlines and tabs between blocks in the HTML source are Text nodes. Without that rule, a release that just reindents the HTML would move all the notes. The SPACE pattern in the code below leaves out the non-breaking space on purpose. CSS doesn’t collapse that one on the page, while JavaScript’s \s matches it:

text-index.js
// text-index.js
const SPACE = /[\t\n\f\r ]/;

function buildTextIndex( $root ) {
	const walker = document.createTreeWalker( $root, NodeFilter.SHOW_TEXT );
	const index = { text: '', map: [] };
	while ( walker.nextNode() ) {
		walker.currentNode.data.split( '' ).forEach( ( char, offset ) => {
			const space = SPACE.test( char );
			if ( !space || /[^ ]$/.test( index.text ) ) {
				index.text += space ? ' ' : char;
				index.map.push( { $node: walker.currentNode, offset } );
			}
		} );
	}
	return index;
}

The index in the demos also normalizes the text with normalize( 'NFC' ). An “é”, for example, can be stored as one character or as an “e” followed by a separate accent mark. They look the same on the page, but JavaScript compares them as different strings. NFC turns both into the one-character form before the site compares anything.

The first demo saved the green note on 1.0 as { "start": 590, "end": 640 }. The site restores it by building the index again on whatever version is open and turning the two numbers back into a range:

offset-notes.js
// offset-notes.js
function rangeFor( index, start, end ) {
	const first = index.map[ start ];
	const last = index.map[ end - 1 ];
	const range = document.createRange();
	range.setStart( first.$node, first.offset );
	range.setEnd( last.$node, last.offset + 1 );
	return range;
}

function restoreOffsets( $root, saved ) {
	const index = buildTextIndex( $root );
	return saved.end <= index.text.length ? rangeFor( index, saved.start, saved.end ) : null;
}

On 1.1, the new paragraph and the space after it add 177 characters above the green sentence. Positions 590 to 640 now fall in the middle of that paragraph, and so does the green note. Those two numbers are all the site saved for it, with nothing to check them against.

Saving the words

The green note’s sentence didn’t change on 1.1. It just moved down the page, but that was enough to break the first way of saving a note.

So, the second way saves the note’s exact words and looks for them on the page. One indexOf() finds the first place they appear:

quote-notes.js
// quote-notes.js
function restoreQuote( $root, saved ) {
	const index = buildTextIndex( $root );
	const start = index.text.indexOf( saved.exact );
	return start === -1 ? null : rangeFor( index, start, start + saved.exact.length );
}

The Drive API also stores the quoted text of a comment, in a field called quotedFileContent next to its position.

In the next demo, the docs site restores each note from its words. All three notes are right on 1.1. Now pick 1.2, the version with a tip in the Quick start that repeats the green note’s sentence.

As the exact text now shows up twice, the green highlight moved up to the tip, and stays there through 1.3 and 1.4.

That’s the second way failing.

The browser’s built-in feature “Text fragments” has the same problem. Chrome’s “Copy link to highlight” puts the words you picked in a part of the address called a text fragment. For a unique sentence as long as the green note’s, Chrome leaves out the text around it. If the text gets modified to add a copy of the text higher up, the link opens on that copy.

Chrome does that on purpose, following the spec’s advice to use context only when necessary. A link that includes text from a neighboring element can break with a change to the page structure. For a link you share, I think that’s the right call. For notes, though, you need a way to tell the copies apart.

So, let’s look at the following approach.

Saving the words and the text around them

The tip repeats the green sentence word for word, but has different text around it.

The third way saves the words plus up to 32 characters of the text on both sides, like Hypothesis does. The W3C’s Web Annotation Data Model has a TextQuoteSelector for this.

Hypothesis counts those characters across the edges of paragraphs and headings. I stop at the edge of the note’s block (the paragraph or heading it’s in), because I don’t want an edit in the paragraph above or below to make the match weaker.

To cut the text at the edges of a block, the index in the demos also records where each block starts and ends. describeSpan() gets the block from those records and describes any piece of the page’s text:

describe.js
// describe.js
const CONTEXT_LENGTH = 32;

function describeSpan( index, start, end ) {
	const block = blockAt( index, start );
	return {
		start,
		end,
		exact: index.text.slice( start, end ),
		prefix: index.text.slice( Math.max( block.start, start - CONTEXT_LENGTH ), start ),
		suffix: index.text.slice( end, Math.min( block.end, end + CONTEXT_LENGTH ) ),
	};
}

The green note’s description on 1.0 comes out as:

json
{
	"start": 590,
	"end": 640,
	"exact": "The cache keeps compiled templates for seven days.",
	"prefix": "red results don't live forever. ",
	"suffix": " After that, the next build comp"
}

The prefix starts halfway through “Stored” because the site counts characters.

To put a note back, the docs site first lists the places on the page that could be its passage. For now, those are the note’s old position and every exact copy of the saved words, found with a loop over indexOf():

candidates.js
// candidates.js
function allIndexesOf( text, words ) {
	const found = [];
	for ( let at = text.indexOf( words ); at !== -1; at = text.indexOf( words, at + 1 ) ) {
		found.push( at );
	}
	return found;
}

The places go in a Map keyed by start and end, in case one turns up twice:

candidates.js
// candidates.js
function findCandidates( index, stored ) {
	const spans = new Map();
	for ( const start of [ stored.start, ...allIndexesOf( index.text, stored.exact ) ] ) {
		const end = start + stored.exact.length;
		spans.set( `${start}:${end}`, { start, end } );
	}
	return [ ...spans.values() ];
}

To compare each possible match with what it saved, the site uses a similarity() function (not shown here). It scores two strings from 0 to 1 by how many one-character edits turn one into the other. score() runs it on the words and on the text on each side:

score.js
// score.js
function score( index, stored, { start, end } ) {
	const found = describeSpan( index, start, end );
	return {
		start,
		end,
		words: similarity( stored.exact, found.exact ),
		before: similarity( stored.prefix, found.prefix ),
		after: similarity( stored.suffix, found.suffix ),
		distance: Math.abs( start - stored.start ),
	};
}

I keep those numbers separate instead of adding them up into one weighted score. If you add them up, perfect words can make up for surrounding text that doesn’t match at all. The thing is, an exact copy somewhere else always has perfect words.

A note counts as placed if the words match exactly and both sides score at least 0.8. The tip on 1.2 has “Tip: ” in front of it, where the original had “…red results don’t live forever. ”. That gives it 0.06 before and 0.22 after, nowhere near 0.8.

Sometimes two places pass, like after a whole paragraph gets copied together with the text around it. The docs site handles that with a function called settle(). It drops any place that scores clearly lower than another one on the words or on either side, and no higher on the rest. With one place left, the note goes there.

If more than one place is left, none of them is clearly better, like when one matches the text before more closely and another the text after. Then the card says Needs review and lists the places as buttons to pick from. The old position doesn’t decide it either, because a paragraph copied right above the original takes over that position.

When it saves a note, the docs site also stores how many places matched the passage equally well, counting the text around them too. For a passage that’s already repeated, the old position breaks the tie if a copy still starts there and that number hasn’t gone up. It’s only a guess, though, because a release could remove one copy and add another, or move them around, without changing that number.

When the words changed

Version 1.3 changes “seven days” to “five days” in the original sentence. The tip in the Quick start still says seven. Now it’s the one exact copy of the green note’s sentence.

On 1.3, Hypothesis’s quote matching code gives exact copies priority and puts the note on the tip. It looks for close matches only on a page with no exact copy.

Hypothesis’s 2013 design had a step that would have found the edited sentence. Its current code doesn’t have that step anymore. I kept it in the demos. Besides the exact copies, the docs site checks the text between a copy of the saved prefix and the next copy of the saved suffix, inside one block. An empty prefix or suffix stands for the start or the end of the block:

candidates.js
// candidates.js, inside findCandidates() before the return
const starts = stored.prefix === ''
	? index.blocks.map( ( block ) => block.start )
	: allIndexesOf( index.text, stored.prefix ).map( ( at ) => at + stored.prefix.length );
for ( const start of starts ) {
	const block = blockAt( index, start );
	const end = stored.suffix === '' ? block.end : index.text.indexOf( stored.suffix, start );
	if ( end > start && end <= block.end ) {
		spans.set( `${start}:${end}`, { start, end } );
	}
}

On 1.3, the edited sentence gets 1 on both sides and 0.94 on its words, where “seven” became “five”.

A note gets Text changed if both sides match and the words are at least 0.8 alike. The note stays on its sentence, with a card that shows what the words used to say and the changed word in bold.

When the passage is gone

Version 1.4 removes the whole paragraph about how long the cache keeps results. Now the tip is the one exact copy left. Its surrounding text doesn’t match.

With nothing passing, the card says Could not place. It moves to a Not placed group at the end of the margin and keeps the saved words, since people recognize a passage more easily than they recall it. On that card, the tip shows up as a possible match, with an Attach here button next to it.

I’d rather show a note as unplaced than put it on a weak match.

In 2000, Brush and Bargeron’s team at Microsoft Research had 12 people make notes on a web page and moved the notes to an updated version with a simple algorithm. Once a passage had changed a fair amount, people rated the notes the algorithm moved no higher than the ones it couldn’t place. The paper calls those orphaned.

In 2001, they tested a new system with people. Below what they call the guess threshold, it showed a note unplaced, with its best match as a guess. Their weakest guesses had been on completely unrelated text, so after the study they added a minimum score for a guess. They write that people’s preferences for the threshold “varied dramatically”, and they suggest starting high.

I picked the numbers in the choose() function below myself, and following that advice, I started them high. If nothing passes, the note stays unplaced. The card offers the place with the closest words as a possible match if they’re at least 0.65 alike:

choose.js
// choose.js
function choose( index, stored ) {
	const candidates = findCandidates( index, stored ).map( ( span ) => score( index, stored, span ) );
	const agrees = ( candidate ) => candidate.before >= 0.8 && candidate.after >= 0.8;
	const placed = candidates.filter( ( candidate ) => candidate.words === 1 && agrees( candidate ) );
	const changed = candidates.filter( ( candidate ) => candidate.words >= 0.8 && agrees( candidate ) );
	if ( placed.length > 0 || changed.length > 0 ) {
		return placed.length > 0 ? settle( 'placed', placed ) : settle( 'changed', changed );
	}
	const guesses = candidates.filter( ( candidate ) => candidate.words >= 0.65 );
	return { answer: 'orphaned', guess: guesses.sort( ( a, b ) => b.words - a.words )[ 0 ] ?? null };
}

Other than skipping text where more than half the characters of the saved words would have to change, Hypothesis’s matching code has no minimum score. I tested it on one of my longer articles, the one about animation-timeline: scroll(), by making notes on 30 sentences and deleting those sentences one at a time. Hypothesis put the notes for 13 of them on other text. It found no match for the other 17.

The next demo runs the whole thing, with the statuses on the cards. Go through 1.1 to 1.4 in order, then press Attach here on the green note’s card and go back to 1.2.

Attach here saves a new description of the tip on the version you’re on. From then on, the note stays on the tip, even on 1.2.

Making a note from a selection

So far, the notes came with the page.

Users make their own by selecting some text first. While they drag across a sentence, the browser fires selectionchange over and over. An “Add note” button that followed those events would keep moving under the pointer.

So, in the demo at the end of the article, the docs site calls a showButton() function on pointerup, and hides the button as soon as a new press starts:

add-note-button.js
// add-note-button.js
let dragging = false;

$content.addEventListener( 'pointerdown', () => {
	dragging = true;
	$button.hidden = true;
} );
document.addEventListener( 'pointerup', () => {
	dragging = false;
	showButton();
} );

Selections made with the keyboard, or with the handles on a phone, don’t end with a pointerup. For those, the button shows up after 100 milliseconds with no selectionchange:

add-note-button.js
// add-note-button.js
let timer = 0;

document.addEventListener( 'selectionchange', () => {
	clearTimeout( timer );
	timer = dragging ? 0 : setTimeout( showButton, 100 );
} );

document.getSelection() returns what the user selected. Its getRangeAt( 0 ) method gives you that selection as a range, the same kind of object the highlights use. When you press “Add note”, the docs site builds the passage’s description from the Text node data that range points at. Selection.toString() would be simpler but returns different text in different browsers.

A selection can be as short as one word, too. Notes on fewer than 16 characters need 0.9 on both sides and never get Text changed, since short text repeats a lot and one changed letter can make it another word.

From the keyboard, you can’t start a selection in a page’s text without caret browsing (a mode that puts a text cursor in the page). Chrome and Firefox turn it on with F7. The NVDA screen reader reads web pages in what it calls browse mode. It doesn’t pass a selection made there on to the page, unless Native Selection Mode is on.

That’s why every paragraph in the final demo gets its own “Add note” button in front of it. Pressing one makes a note on the whole block. Since a note like that has no text around it inside its block, the site saves the text of the blocks before and after it instead. For those notes only, one matching side is enough. The buttons sit between paragraphs, where they don’t split any Text node, and carry data-anchor-ignore, an attribute the index skips.

Control+Alt+M (Command+Option+M on a Mac) adds a note on the selection, the same shortcut Google Docs uses to insert a comment.

Opening a note from its highlight

Highlights are just paint and don’t get any events. To open a note’s card on a click, you have to find which highlight is under the pointer.

CSS.highlights.highlightsFromPoint( x, y ) returns the highlights at a point, with the ranges hit at that point. Where a browser doesn’t have it, the site checks whether the click falls inside one of the rectangles that getClientRects() returns for a range. Either way, you need a Map from the ranges you painted to their notes:

hit-test.js
// hit-test.js
function noteAt( x, y ) {
	if ( CSS.highlights.highlightsFromPoint ) {
		const ranges = CSS.highlights.highlightsFromPoint( x, y ).flatMap( ( hit ) => hit.ranges );
		return noteByRange.get( ranges.find( ( range ) => noteByRange.has( range ) ) ) ?? null;
	}
	const inside = ( rect ) => x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
	for ( const [ range, id ] of noteByRange ) {
		if ( [ ...range.getClientRects() ].some( inside ) ) {
			return id;
		}
	}
	return null;
}
Browser supporthighlightsFromPoint72% of users
Chrome140
Edge140
Firefox150
SafariPreview
Safari iOSNo
Chrome Android140

Browser data updated October 22, 2026

Screen readers may say nothing about a highlight. Only some pairs of browser and screen reader announce one, as marked or highlighted. That’s why the notes in the margin are the accessible part of the feature.

Every card holds the passage as text. That text is also a button that brings the passage into view, as long as the note is on the page.

No status depends on color alone. Placed notes get a solid underline, changed ones a dashed one. Cards also show an icon for the status, plus a visible label for all statuses except Placed.

After a version change or an Attach here, the docs site in the third demo and the final one writes the result into a role="status" region, like “Version 1.3: 2 notes placed, 1 changed.” Screen readers can announce the result without moving focus. MDN recommends putting the region in the initial markup, since screen readers may skip a live region that’s added together with its message.

After Attach here, the docs site moves focus to that note’s card, because the button you pressed goes away with the offer.

Text inside a shadow root

The npm run clean code sample is a custom element. It renders its text inside its own shadow root (a separate tree of nodes attached to the element, where the page’s styles don’t apply).

Browsers return different things from getRangeAt() for a selection inside a shadow root.

getComposedRanges() returns where the selection really starts and ends. The ranges it returns are StaticRange objects, which don’t update when the DOM changes and have no getClientRects(). To measure one on screen, you copy its start and end into a live range.

You have to list every shadow root the selection can be in, as an option called shadowRoots. An end inside a root you leave out moves to just outside its custom element. In Safari, that happens even if a root you listed is inside it.

Firefox can return more than one range, since it lets a selection have several pieces. When only whitespace sits between them, the passage runs from the start of the first one to the end of the last one. Otherwise the docs site doesn’t make a note, because the text between the pieces isn’t selected. The snippet below joins the pieces and leaves that check out:

read-selection.js
// read-selection.js
function readSelection() {
	const shadowRoots = [ ...$content.querySelectorAll( 'code-sample' ) ].map( ( $sample ) => $sample.shadowRoot );
	const ranges = document.getSelection().getComposedRanges( { shadowRoots } );
	const first = ranges.find( ( range ) => !range.collapsed );
	const last = ranges.findLast( ( range ) => !range.collapsed );
	return first ? new StaticRange( {
		startContainer: first.startContainer,
		startOffset: first.startOffset,
		endContainer: last.endContainer,
		endOffset: last.endOffset,
	} ) : null;
}

highlightsFromPoint() takes the same shadowRoots list as an option, for highlights inside those roots.

Your text index needs to go into the shadow root as well. textContent, innerText, and a TreeWalker all skip shadow content. The docs site walks the tree by hand instead, going into open shadow roots in the order the page renders their text.

Your ::highlight() rules need a copy inside the shadow root’s styles too. Firefox doesn’t paint text inside a shadow root from a rule in the page’s stylesheet, because it doesn’t pass highlight styles down the tree as the spec requires (bug 1999628).

The final demo is the finished feature, starting with the three notes from before.

  1. Select a few words in a paragraph, or in the npm run clean code sample, and press Add note.
  2. Type a note and press Save.
  3. Pick another version to see where your note ends up, and click any highlight to open its card.

From the keyboard, Tab to the Add note button in front of a paragraph, or select text with caret browsing and press Control+Alt+M.

text-anchor.ts
// index.ts
import type {
	AnchorOptions,
	Answer,
	Candidate,
	CandidateSource,
	FoundSpan,
	IndexedText,
	Location,
	Match,
	PendingCluster,
	SourceRun,
	StoredAnchor,
	TextIndex,
	TextPiece,
	TextSource,
	TextSpan,
} from './types';

/**
 * How many characters of context a note stores on each side of its passage.
 *
 * It's long enough to tell a sentence from a copy of it in another paragraph, and short enough that an edit a few sentences away never touches it.
 */
export const CONTEXT_LENGTH = 32;

/**
 * How alike each side's context has to be, from 0 to 1, before the place counts as the passage's place.
 *
 * An exact copy of the words in another paragraph has the right words but the wrong surroundings, and its context scores come out below this value.
 * A place has to reach it on both sides, or on one side when the note covered a whole block and the place fills one.
 */
export const CONTEXT_AGREES = 0.8;

/**
 * Below this many characters, a passage counts as short.
 *
 * A short passage, a word or a command, can appear in many places on a page.
 */
export const SHORT_PASSAGE = 16;

/**
 * How alike each side's context has to be for a short passage.
 *
 * Higher than `CONTEXT_AGREES`, because a short passage leans on its context to tell one copy from another.
 */
export const SHORT_CONTEXT_AGREES = 0.9;

/**
 * How alike the words have to be, from 0 to 1, for a place whose context agrees to count as the passage with its text changed.
 *
 * Below it, the words changed enough that the user's note may not apply anymore, even when the surroundings are the same.
 * Short passages never get this answer, since one edit to a short word changes what it says.
 */
export const WORDS_CHANGED = 0.8;

/**
 * How alike the words have to be, from 0 to 1, for a place to be offered as a possible match when nothing could be placed.
 *
 * A candidate below it is never offered to the user as the possible match.
 */
export const GUESS_FLOOR = 0.65;

/**
 * How close two scores have to be to count as equal.
 *
 * Two places whose context differs by one character in 32 still tie.
 */
export const TIE_MARGIN = 0.05;

/**
 * How much longer than the stored words a span found through the context may be, as a share of their length.
 *
 * It caps the length of a span found through the context, inside the block where the span starts.
 */
export const SPAN_GROWTH = 0.5;

/**
 * The extra characters a span found through the context may have on top of `SPAN_GROWTH`.
 *
 * A short passage gets room for a few added words even though half its length is only a few characters.
 */
export const SPAN_SLACK = 16;

/**
 * Matches one character of the ASCII whitespace that HTML collapses, which the index treats as whitespace.
 *
 * A non-breaking space isn't in the list, because a page writes it on purpose and CSS keeps it.
 */
const WHITESPACE = /^[\t\n\f\r ]$/;

/**
 * One unit of raw text: a single whitespace character, or one character plus the combining marks after it.
 *
 * Whitespace is a unit of its own, so a space or a tab before a combining mark collapses like any other whitespace.
 * `joins()` tests which units have to be normalized together, inside one piece or across several.
 */
const CLUSTER = /[\t\n\f\r ]|[^\t\n\f\r \p{M}]\p{M}*|\p{M}+/gu;

/**
 * Tells whether a unit of raw text has to be NFC-normalized together with the text before it.
 *
 * A unit that starts with a combining mark always does.
 * Any other unit does when NFC gives one result for the two together and another for each one alone, as it does for Hangul written as separate letters.
 * It compares NFC results instead of checking a list of scripts, because a list misses the characters a new version of Unicode adds.
 * Nothing below U+0300 composes with the character before it, so for ASCII text the function returns before any normalize call.
 *
 * @param before - The raw text of the unit that isn't written to the page text yet.
 * @param cluster - The next unit.
 * @returns Whether the two are normalized as one unit.
 */
function joins( before: string, cluster: string ): boolean {
	return /^\p{M}/u.test( cluster ) || ( cluster.charCodeAt( 0 ) >= 0x300
		&& ( before + cluster ).normalize( 'NFC' ) !== before.normalize( 'NFC' ) + cluster.normalize( 'NFC' ) );
}

/**
 * The elements that start and end a block when the app passes no selector of its own.
 *
 * It lists the block containers a docs page is built from.
 * The text of two of them never runs together, even when one is empty or the HTML has no whitespace between them.
 */
export const DEFAULT_BLOCKS = [
	'article', 'aside', 'blockquote', 'dd', 'details', 'div', 'dl', 'dt', 'figcaption', 'figure', 'footer',
	'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre',
	'section', 'summary', 'table', 'td', 'th', 'tr', 'ul',
].join( ', ' );

/**
 * The elements whose text the index skips when the app passes no `ignore` function of its own.
 */
const SKIPPED = 'script, style, noscript, template, [data-anchor-ignore]';

/**
 * Builds the page text from pieces of raw text, with a map from every position back to its piece.
 *
 * Every run of whitespace, every line break, and every edge between blocks becomes one space, and the text never starts or ends with one.
 * The stored words of a note come out of the same rule as the page text they're compared with.
 * Two paragraphs never run together, whether or not the HTML source has a newline between them.
 * Each character is NFC-normalized together with the characters that compose with it, even when they start the next piece of the same block.
 * An accent typed as two code points then matches the same accent typed as one, and Hangul typed as separate letters matches the same syllables typed whole.
 * Whitespace, a block edge, and a line break end that run, so a combining mark after one of them never composes with the text before it.
 *
 * @param pieces - The raw text, in reading order, with the block each piece is in.
 * @returns The page text, the blocks, and the map back to the pieces.
 */
export function indexPieces( pieces: readonly TextPiece[] ): IndexedText {
	let text = '';
	const piece: number[] = [];
	const from: number[] = [];
	const endPiece: number[] = [];
	const to: number[] = [];
	const blocks: TextSpan[] = [];
	let space: SourceRun | null = null;
	let open: TextSpan | null = null;
	let pending: PendingCluster | null = null;
	let current: unknown;

	/**
	 * Writes the pending unit to the page text after NFC, and maps every position it becomes back to its raw characters.
	 */
	const flush = (): void => {
		if ( !pending || !open ) {
			return;
		}
		const normalized = pending.raw.normalize( 'NFC' );
		for ( let unit = 0; unit < normalized.length; unit++ ) {
			piece.push( pending.piece );
			from.push( pending.from );
			endPiece.push( pending.endPiece );
			to.push( pending.to );
		}
		text += normalized;
		open.end = text.length;
		pending = null;
	};

	pieces.forEach( ( { data, block, breakBefore }, index ) => {
		if ( index === 0 || block !== current ) {
			flush();
			open = null;
			space ??= { piece: index, from: 0, to: 0 };
			current = block;
		} else if ( breakBefore ) {
			flush();
			space ??= { piece: index, from: 0, to: 0 };
		}
		for ( const match of data.matchAll( CLUSTER ) ) {
			const cluster = match[ 0 ];
			const offset = match.index;
			if ( WHITESPACE.test( cluster ) ) {
				flush();
				if ( !space ) {
					space = { piece: index, from: offset, to: offset + 1 };
				} else if ( space.piece === index && space.to === offset ) {
					space.to = offset + 1;
				}
				continue;
			}
			if ( pending && joins( pending.raw, cluster ) ) {
				pending.raw += cluster;
				pending.endPiece = index;
				pending.to = offset + cluster.length;
				continue;
			}
			flush();
			if ( space && text.length > 0 ) {
				text += ' ';
				piece.push( space.piece );
				from.push( space.from );
				endPiece.push( space.piece );
				to.push( space.to );
			}
			space = null;
			if ( !open ) {
				open = { start: text.length, end: text.length };
				blocks.push( open );
			}
			pending = { raw: cluster, piece: index, from: offset, endPiece: index, to: offset + cluster.length };
		}
	} );
	flush();

	return { text, blocks, piece, from, endPiece, to };
}

/**
 * Finds which block a position falls in, by its place in the list of blocks.
 *
 * Because the blocks come in reading order and never overlap, a binary search over their starts finds the block in a few steps per candidate.
 *
 * @param blocks - The blocks of the page text.
 * @param position - A position in the page text.
 * @returns The index of the last block that starts at or before the position, or 0 when there are no blocks.
 */
function blockIndex( blocks: readonly TextSpan[], position: number ): number {
	let low = 0;
	let high = Math.max( 0, blocks.length - 1 );
	while ( low < high ) {
		const middle = Math.ceil( ( low + high ) / 2 );
		if ( blocks[ middle ].start <= position ) {
			low = middle;
		} else {
			high = middle - 1;
		}
	}
	return low;
}

/**
 * Finds the block a position falls in, whose edges a span found through the context must stay inside.
 *
 * A source with no blocks, such as a plain string in a test, counts as one block.
 *
 * @param source - The page text and its blocks.
 * @param position - A position in the page text.
 * @returns The block, or the whole text when there are no blocks.
 */
function blockAt( source: TextSource, position: number ): TextSpan {
	return source.blocks.at( blockIndex( source.blocks, position ) ) ?? { start: 0, end: source.text.length };
}

/**
 * Tells whether a span starts where a block starts and ends where a block ends, as a note on a whole paragraph does.
 *
 * @param source - The page text and its blocks.
 * @param start - The first position of the span.
 * @param end - The position right after the span.
 * @returns Whether the span fills its block, or a run of whole blocks.
 */
function fillsBlock( source: TextSource, start: number, end: number ): boolean {
	const { blocks } = source;
	return blocks.length > 0
		&& blocks[ blockIndex( blocks, start ) ].start === start
		&& blocks[ blockIndex( blocks, end - 1 ) ].end === end;
}

/**
 * Finds how far the context around a span may reach on each side.
 *
 * The context stops at the edges of the span's block, out of reach of an edit in a heading or in the previous paragraph.
 * A span that fills its block would get empty context on both sides there, and empty context matches every other whole block.
 * Such a span reaches one block further on each side, to the end of the previous block and the start of the next one.
 * When the note covered a whole block too, `contextAgrees()` needs only one of those two neighbors to match.
 *
 * @param source - The page text and its blocks.
 * @param start - The first position of the span.
 * @param end - The position right after the span.
 * @returns The first position the text before may use, and the position right after the last one the text after may use.
 */
function contextReach( source: TextSource, start: number, end: number ): TextSpan {
	const { blocks, text } = source;
	if ( blocks.length === 0 ) {
		return { start: 0, end: text.length };
	}
	const first = blockIndex( blocks, start );
	const last = blockIndex( blocks, end - 1 );
	if ( !fillsBlock( source, start, end ) ) {
		return { start: blocks[ first ].start, end: blocks[ last ].end };
	}
	return { start: first > 0 ? blocks[ first - 1 ].start : 0, end: blocks.at( last + 1 )?.end ?? text.length };
}

/**
 * Returns the context before a span, up to `CONTEXT_LENGTH` characters and no further than `contextReach()` allows.
 *
 * @param source - The page text and its blocks.
 * @param start - The first position of the span.
 * @param end - The position right after the span, needed to tell whether the span fills its block.
 * @returns The text right before the span.
 */
function contextBefore( source: TextSource, start: number, end: number ): string {
	const reach = contextReach( source, start, end );
	return source.text.slice( Math.max( reach.start, start - CONTEXT_LENGTH ), start );
}

/**
 * Returns the context after a span, up to `CONTEXT_LENGTH` characters and no further than `contextReach()` allows.
 *
 * @param source - The page text and its blocks.
 * @param start - The first position of the span, needed to tell whether the span fills its block.
 * @param end - The position right after the span.
 * @returns The text right after the span.
 */
function contextAfter( source: TextSource, start: number, end: number ): string {
	const reach = contextReach( source, start, end );
	return source.text.slice( end, Math.min( reach.end, end + CONTEXT_LENGTH ) );
}

/**
 * Drops the spaces at both ends of a span, after clamping it to the text.
 *
 * A selection often starts or ends on the space between words, and the index never stores a passage that starts with one.
 *
 * @param text - The page text.
 * @param start - The first position.
 * @param end - The position right after the last one.
 * @returns The trimmed span, or null when nothing but whitespace is left.
 */
function trimSpan( text: string, start: number, end: number ): TextSpan | null {
	let first = Math.max( 0, Math.min( start, text.length ) );
	let last = Math.max( first, Math.min( end, text.length ) );
	while ( first < last && text[ first ] === ' ' ) {
		first++;
	}
	while ( last > first && text[ last - 1 ] === ' ' ) {
		last--;
	}
	return first < last ? { start: first, end: last } : null;
}

/**
 * Counts the single-character edits that turn one string into the other (Levenshtein distance).
 *
 * @param a - One string.
 * @param b - The other string.
 * @returns The number of insertions, deletions, and substitutions.
 */
export function editDistance( a: string, b: string ): number {
	let previous = Array.from( { length: b.length + 1 }, ( _, index ) => index );
	for ( let row = 1; row <= a.length; row++ ) {
		const next = [ row ];
		for ( let column = 1; column <= b.length; column++ ) {
			const substitution = previous[ column - 1 ] + ( a[ row - 1 ] === b[ column - 1 ] ? 0 : 1 );
			next.push( Math.min( previous[ column ] + 1, next[ column - 1 ] + 1, substitution ) );
		}
		previous = next;
	}
	return previous[ b.length ];
}

/**
 * Scores how alike two strings are, from 0 for nothing in common to 1 for identical.
 *
 * With the edit distance divided by the longer length, a missing context counts against a candidate as much as a different one.
 * Two empty strings are identical, which is how two passages that both start at the edge of their block agree.
 *
 * @param a - One string.
 * @param b - The other string.
 * @returns The similarity.
 */
export function similarity( a: string, b: string ): number {
	const longest = Math.max( a.length, b.length );
	return longest === 0 ? 1 : 1 - editDistance( a, b ) / longest;
}

/**
 * Describes a span of the page text as the shape a note stores.
 *
 * `findPassage()` measures each candidate's context with the same `contextReach()` rule used here.
 * The description also says whether the span fills its block, and how many places tie with it on this page when there's more than one, as with a paragraph that appears twice.
 * With that count, a note attached to one of two identical paragraphs stays on it.
 *
 * @param source - The page text and its blocks.
 * @param start - The first position of the passage.
 * @param end - The position right after the passage.
 * @returns The stored shape, or null when the span holds only whitespace.
 */
export function describeSpan( source: TextSource, start: number, end: number ): StoredAnchor | null {
	const span = trimSpan( source.text, start, end );
	if ( !span ) {
		return null;
	}
	const stored: StoredAnchor = {
		start: span.start,
		end: span.end,
		exact: source.text.slice( span.start, span.end ),
		prefix: contextBefore( source, span.start, span.end ),
		suffix: contextAfter( source, span.start, span.end ),
	};
	if ( fillsBlock( source, span.start, span.end ) ) {
		stored.wholeBlock = true;
	}
	const agrees = contextTest( source, stored );
	const passing = scoreCandidates( source, stored ).filter( ( candidate ) => candidate.words === 1 && agrees( candidate ) );
	const itself = passing.find( ( candidate ) => candidate.start === span.start && candidate.end === span.end );
	const copies = itself ? distinctPlaces( passing ).places.filter( ( place ) => ties( place, itself ) ).length : 1;
	if ( copies > 1 ) {
		stored.copies = copies;
	}
	return stored;
}

/**
 * Lists the positions right after each copy of the stored prefix, where a span found through the context can start.
 *
 * When the stored prefix is empty, as it is for a passage that started its block, every block start counts.
 *
 * @param source - The page text and its blocks.
 * @param prefix - The stored prefix.
 * @returns The possible starts.
 */
function contextStarts( source: TextSource, prefix: string ): number[] {
	if ( !prefix ) {
		return source.blocks.map( ( block ) => block.start );
	}
	const starts: number[] = [];
	for ( let at = source.text.indexOf( prefix ); at !== -1; at = source.text.indexOf( prefix, at + 1 ) ) {
		starts.push( at + prefix.length );
	}
	return starts;
}

/**
 * Finds the end of a span found through the context, at the first copy of the stored suffix close enough after its start.
 *
 * The span runs to the end of the block when the stored suffix is empty, as it is for a passage that ended its block.
 * The span never leaves the block it starts in.
 *
 * @param source - The page text and its blocks.
 * @param start - Where the span starts.
 * @param suffix - The stored suffix.
 * @param longest - The longest the span may be.
 * @returns The end of the span, or null when the suffix isn't close enough or is in another block.
 */
function contextEnd( source: TextSource, start: number, suffix: string, longest: number ): number | null {
	const block = blockAt( source, start );
	const end = suffix ? source.text.indexOf( suffix, start ) : block.end;
	return end > start && end <= block.end && end - start <= longest ? end : null;
}

/**
 * Lists the whole blocks right after a copy of the stored prefix and right before a copy of the stored suffix.
 *
 * A passage that fills its block stored the end of the block before it as its prefix and the start of the block after it as its suffix.
 * When a release edits the passage and also changes one of those neighbors, no span runs from a copy of the prefix to a copy of the suffix anymore.
 * The block next to the neighbor that stayed the same is still a candidate.
 *
 * @param source - The page text and its blocks.
 * @param prefix - The stored prefix.
 * @param suffix - The stored suffix.
 * @returns The blocks, possibly with repeats.
 */
function neighborBlocks( source: TextSource, prefix: string, suffix: string ): TextSpan[] {
	const { text } = source;
	const found: TextSpan[] = [];
	for ( let at = prefix ? text.indexOf( prefix ) : -1; at !== -1; at = text.indexOf( prefix, at + 1 ) ) {
		const block = blockAt( source, at + prefix.length );
		if ( block.start === at + prefix.length ) {
			found.push( block );
		}
	}
	for ( let at = suffix ? text.indexOf( suffix ) : -1; at !== -1; at = text.indexOf( suffix, at + 1 ) ) {
		const block = blockAt( source, at - 1 );
		if ( block.end === at ) {
			found.push( block );
		}
	}
	return found;
}

/**
 * Tells whether the text around a candidate agrees with the stored context.
 *
 * Both sides have to reach the floor, except for a note that covered a whole block at a candidate that fills a block, which needs one side.
 * A note on a whole paragraph stored the text of the blocks before and after it, and a release can add, edit, or remove either of them without touching the paragraph.
 * An exact copy of the paragraph with other blocks on both sides still fails.
 * A note on part of a paragraph never gets the exception, or a copy of its words that fills a block of its own could pass on one side.
 *
 * @param source - The page text and its blocks.
 * @param candidate - The candidate.
 * @param floor - How alike a side has to be, from 0 to 1.
 * @param wholeBlock - Whether the note covered a whole block.
 * @returns Whether the context agrees.
 */
function contextAgrees( source: TextSource, candidate: Candidate, floor: number, wholeBlock: boolean ): boolean {
	const sides = [ candidate.before, candidate.after ].filter( ( side ) => side >= floor ).length;
	return sides === 2 || ( sides === 1 && wholeBlock && fillsBlock( source, candidate.start, candidate.end ) );
}

/**
 * Builds the test a candidate's context has to pass for a stored passage.
 *
 * A short passage needs a higher floor, and only a note that covered a whole block gets the one-side rule.
 *
 * @param source - The page text and its blocks.
 * @param stored - What the note stored.
 * @returns The test.
 */
function contextTest( source: TextSource, stored: StoredAnchor ): ( candidate: Candidate ) => boolean {
	const floor = stored.exact.normalize( 'NFC' ).length < SHORT_PASSAGE ? SHORT_CONTEXT_AGREES : CONTEXT_AGREES;
	return ( candidate ) => contextAgrees( source, candidate, floor, stored.wholeBlock === true );
}

/**
 * Tells whether one candidate is no worse than another on every signal and ahead by more than the tie margin on at least one.
 *
 * Comparing signal by signal, instead of adding them into one number, means a strong word match can never make up for context that disagrees.
 * Being no worse on every signal keeps the wins from going in a circle, where three candidates each beat the next one and none is left.
 * A lead within the tie margin doesn't count, so two near-identical places stay a tie for `settle()` to break.
 *
 * @param a - The candidate that might be better.
 * @param b - The candidate it's compared with.
 * @returns Whether `a` beats `b`.
 */
function dominates( a: Candidate, b: Candidate ): boolean {
	const signals = [ [ a.words, b.words ], [ a.before, b.before ], [ a.after, b.after ] ];
	return signals.every( ( [ mine, theirs ] ) => mine >= theirs )
		&& signals.some( ( [ mine, theirs ] ) => mine > theirs + TIE_MARGIN );
}

/**
 * Tells whether two candidates score the same on every signal, within the tie margin.
 *
 * @param a - One candidate.
 * @param b - The other candidate.
 * @returns Whether they tie.
 */
function ties( a: Candidate, b: Candidate ): boolean {
	return Math.abs( a.words - b.words ) <= TIE_MARGIN
		&& Math.abs( a.before - b.before ) <= TIE_MARGIN
		&& Math.abs( a.after - b.after ) <= TIE_MARGIN;
}

/**
 * Narrows the candidates that passed down to separate places.
 *
 * A candidate that another one beats on the signals drops out first.
 * Then spans that start at the same position count as one place, and the one whose context matches best is kept, then the one with the better words, then the nearer one.
 * When an edit changes a passage's length by one character, the text at the old position and the span between the copies of the stored context start together and end one character apart, and both are the same passage.
 * Copies that only overlap, as in a run of repeated characters, stay separate places.
 *
 * @param passing - The candidates that cleared the thresholds.
 * @returns The candidates no other one beats, and the places they make.
 */
function distinctPlaces( passing: Candidate[] ): { undominated: Candidate[]; places: Candidate[] } {
	const undominated = passing.filter( ( candidate ) => !passing.some( ( other ) => dominates( other, candidate ) ) );
	const fit = ( candidate: Candidate ) => Math.min( candidate.before, candidate.after );
	const ranked = [ ...undominated ].sort( ( a, b ) => fit( b ) - fit( a )
		|| b.words - a.words
		|| a.distance - b.distance );
	const places: Candidate[] = [];
	for ( const candidate of ranked ) {
		if ( !places.some( ( kept ) => kept.start === candidate.start ) ) {
			places.push( candidate );
		}
	}
	return { undominated, places };
}

/**
 * Picks the winner among the candidates that passed, or reports that the user has to choose.
 *
 * A single candidate no other one beats wins, and so does a single place once spans with the same start count as one.
 * The old position breaks a tie only when exactly one tied place starts there and the passage already had that many places when it was saved.
 * A paragraph copied right above the original takes the original's old position, and the page text alone can't say which copy is new.
 * So a tie with a place the note didn't have goes to the user, nearest first.
 *
 * @param answer - The answer a single winner gets.
 * @param passing - The candidates that cleared the thresholds for that answer.
 * @param candidates - Every candidate, for the result.
 * @param copies - How many places tied with the passage when the note was saved.
 * @returns The match.
 */
function settle( answer: Answer, passing: Candidate[], candidates: Candidate[], copies: number ): Match {
	const { undominated, places } = distinctPlaces( passing );
	for ( const best of [ undominated, places ] ) {
		if ( best.length === 1 ) {
			return { answer, chosen: best, guess: null, candidates };
		}
		const home = best.filter( ( candidate ) => candidate.distance === 0 );
		if ( home.length === 1 && best.length <= copies && best.every( ( candidate ) => ties( candidate, best[ 0 ] ) ) ) {
			return { answer, chosen: home, guess: null, candidates };
		}
	}
	const nearest = [ ...places ].sort( ( a, b ) => a.distance - b.distance );
	return { answer: 'ambiguous', chosen: nearest, guess: null, candidates };
}

/**
 * Lists the places in the page text that could be a stored passage, and scores each one.
 *
 * The candidates are the text at the stored position, every exact copy of the words, every span between a copy of the stored prefix and the stored suffix, and every whole block right after a copy of the prefix or right before a copy of the suffix.
 * Only the last two kinds find a passage whose words were edited.
 * Each candidate gets its own scores for the words, the text before, the text after, and the distance from the old position.
 *
 * @param source - The page text and its blocks.
 * @param stored - What the note stored, with words that aren't empty.
 * @returns The candidates, best words first.
 */
function scoreCandidates( source: TextSource, stored: StoredAnchor ): Candidate[] {
	const exact = stored.exact.normalize( 'NFC' );
	const prefix = stored.prefix.normalize( 'NFC' );
	const suffix = stored.suffix.normalize( 'NFC' );
	const { text } = source;

	const spans = new Map<string, FoundSpan>();
	const add = ( start: number, end: number, found: CandidateSource ): void => {
		const span = trimSpan( text, start, end );
		if ( !span ) {
			return;
		}
		const key = `${span.start}:${span.end}`;
		const entry = spans.get( key ) ?? { span, found: new Set<CandidateSource>() };
		entry.found.add( found );
		spans.set( key, entry );
	};

	add( stored.start, stored.start + exact.length, 'position' );
	for ( let at = text.indexOf( exact ); at !== -1; at = text.indexOf( exact, at + 1 ) ) {
		add( at, at + exact.length, 'quote' );
	}
	const longest = Math.ceil( exact.length * ( 1 + SPAN_GROWTH ) ) + SPAN_SLACK;
	for ( const start of contextStarts( source, prefix ) ) {
		const end = contextEnd( source, start, suffix, longest );
		if ( end !== null ) {
			add( start, end, 'context' );
		}
	}
	for ( const block of neighborBlocks( source, prefix, suffix ) ) {
		if ( block.end - block.start <= longest ) {
			add( block.start, block.end, 'context' );
		}
	}

	const candidates: Candidate[] = [ ...spans.values() ].map( ( { span, found } ) => {
		const words = text.slice( span.start, span.end );
		return {
			start: span.start,
			end: span.end,
			text: words,
			words: similarity( exact, words ),
			before: similarity( prefix, contextBefore( source, span.start, span.end ) ),
			after: similarity( suffix, contextAfter( source, span.start, span.end ) ),
			distance: Math.abs( span.start - stored.start ),
			found: [ ...found ],
		};
	} );
	return candidates.sort( ( a, b ) => b.words - a.words
		|| Math.min( b.before, b.after ) - Math.min( a.before, a.after )
		|| a.distance - b.distance );
}

/**
 * Looks for a stored passage in the page text and gives it one of the four answers.
 *
 * @param source - The page text and its blocks.
 * @param stored - What the note stored.
 * @returns The answer, the chosen places, the guess, and every candidate with its signals.
 */
export function findPassage( source: TextSource, stored: StoredAnchor ): Match {
	const exact = stored.exact.normalize( 'NFC' );
	if ( !exact ) {
		return { answer: 'orphaned', chosen: [], guess: null, candidates: [] };
	}
	const candidates = scoreCandidates( source, stored );
	const agrees = contextTest( source, stored );
	const copies = stored.copies ?? 1;

	const placed = candidates.filter( ( candidate ) => candidate.words === 1 && agrees( candidate ) );
	if ( placed.length > 0 ) {
		return settle( 'placed', placed, candidates, copies );
	}
	const changed = exact.length < SHORT_PASSAGE
		? []
		: candidates.filter( ( candidate ) => candidate.words >= WORDS_CHANGED && agrees( candidate ) );
	if ( changed.length > 0 ) {
		return settle( 'changed', changed, candidates, copies );
	}
	const guess = candidates.find( ( candidate ) => candidate.words >= GUESS_FLOOR ) ?? null;
	return { answer: 'orphaned', chosen: [], guess, candidates };
}

/**
 * Lists the nodes the walk visits inside an element, in the order the page renders them.
 *
 * For a shadow host, the walk goes into its shadow root, the part the page renders in place of the host's own children.
 * A slot shows the nodes assigned to it, or its fallback content when nothing is.
 * `textContent`, `innerText`, and a TreeWalker all skip shadow content, which is why the index walks by hand.
 *
 * @param $element - The element to read.
 * @returns The nodes to visit next.
 */
function renderedChildren( $element: Element ): Node[] {
	if ( $element.shadowRoot ) {
		return [ $element.shadowRoot ];
	}
	if ( $element instanceof HTMLSlotElement ) {
		const assigned = $element.assignedNodes( { flatten: true } );
		return assigned.length > 0 ? assigned : Array.from( $element.childNodes );
	}
	return Array.from( $element.childNodes );
}

/**
 * Builds the page text over a root element and keeps the way back from each position to its Text node.
 *
 * It takes Text node data, the characters a Range counts, and never `innerText`, which collapses whitespace its own way, drops hidden text, and applies `text-transform`.
 * It walks into open shadow roots in reading order, and skips the elements the app marks as not part of the content.
 * Entering or leaving a block element starts a new block.
 * Even an empty block, such as an `hr`, keeps the text on either side of it apart.
 * A `<br>` holds no text and counts as a space, like the line break the page shows for it.
 *
 * @param $root - The element that holds the content.
 * @param options - What the app says about its markup.
 * @returns The index.
 */
export function buildTextIndex( $root: Node, options: AnchorOptions = {} ): TextIndex {
	const blocks = options.blocks ?? DEFAULT_BLOCKS;
	const ignore = options.ignore ?? ( ( $element: Element ) => $element.matches( SKIPPED ) );
	const pieces: TextPiece[] = [];
	const $nodes: Text[] = [];
	const enter = new Map<Node, number>();
	const exit = new Map<Node, number>();
	let block = 0;
	let breakBefore = false;

	const visit = ( $node: Node ): void => {
		enter.set( $node, pieces.length );
		if ( $node instanceof Text ) {
			pieces.push( { data: $node.data, block, breakBefore } );
			$nodes.push( $node );
			breakBefore = false;
		} else if ( $node instanceof Element ) {
			if ( !ignore( $node ) ) {
				const edge = $node.matches( blocks );
				block += edge ? 1 : 0;
				breakBefore ||= $node.localName === 'br';
				for ( const $child of renderedChildren( $node ) ) {
					visit( $child );
				}
				block += edge ? 1 : 0;
			}
		} else if ( $node instanceof DocumentFragment ) {
			for ( const $child of Array.from( $node.childNodes ) ) {
				visit( $child );
			}
		}
		exit.set( $node, pieces.length );
	};
	visit( $root );

	return { ...indexPieces( pieces ), $nodes, enter, exit };
}

/**
 * Finds the first position in the page text for which a test holds, for a test that's false and then true.
 *
 * @param index - The text index.
 * @param test - The test, given a position.
 * @returns The first position that passes, or the length of the text when none does.
 */
function firstPosition( index: TextIndex, test: ( position: number ) => boolean ): number {
	let low = 0;
	let high = index.text.length;
	while ( low < high ) {
		const middle = Math.floor( ( low + high ) / 2 );
		if ( test( middle ) ) {
			high = middle;
		} else {
			low = middle + 1;
		}
	}
	return low;
}

/**
 * Turns one boundary point of a range into a position in the page text.
 *
 * A boundary inside a Text node maps through the characters that node contributed.
 * A boundary between nodes, the kind a triple-click makes, maps to the first text after it.
 * A start is compared with where each position's characters end, and an end with where they start.
 * When a character's combining mark sits in the next Text node, a boundary between the letter and the mark maps to a position that includes the whole character.
 *
 * @param index - The text index.
 * @param $container - The boundary's container.
 * @param offset - The boundary's offset in the container.
 * @param edge - Whether the boundary starts or ends the range.
 * @returns The position, or null when the boundary is outside the indexed content.
 */
function positionOf( index: TextIndex, $container: Node, offset: number, edge: 'start' | 'end' ): number | null {
	if ( $container instanceof Text ) {
		const piece = index.enter.get( $container );
		if ( piece === undefined ) {
			return null;
		}
		return firstPosition( index, edge === 'start'
			? ( at ) => index.endPiece[ at ] > piece || ( index.endPiece[ at ] === piece && index.to[ at ] > offset )
			: ( at ) => index.piece[ at ] > piece || ( index.piece[ at ] === piece && index.from[ at ] >= offset ) );
	}
	const $child = $container.childNodes[ offset ];
	const pieces = $child ? index.enter.get( $child ) : index.exit.get( $container );
	if ( pieces === undefined ) {
		return null;
	}
	return firstPosition( index, edge === 'start'
		? ( at ) => index.endPiece[ at ] >= pieces
		: ( at ) => index.piece[ at ] >= pieces );
}

/**
 * Converts a range into a span of the page text.
 *
 * Any AbstractRange works here, a StaticRange from `getComposedRanges()` that crosses into a shadow root as well as a live Range.
 *
 * @param index - The text index.
 * @param range - The range.
 * @returns The span with the spaces at its ends dropped, or null when the range is outside the content or holds only whitespace.
 */
export function spanOf( index: TextIndex, range: AbstractRange ): TextSpan | null {
	const start = positionOf( index, range.startContainer, range.startOffset, 'start' );
	const end = positionOf( index, range.endContainer, range.endOffset, 'end' );
	if ( start === null || end === null ) {
		return null;
	}
	return trimSpan( index.text, start, end );
}

/**
 * Builds a fresh range over a span of the page text.
 *
 * When both ends share a root, it's a live Range, ready for painting, geometry, and hit testing.
 * A span that crosses into a shadow root comes back as a StaticRange.
 *
 * @param index - The text index.
 * @param start - The first position.
 * @param end - The position right after the last one.
 * @returns The range.
 */
export function rangeFor( index: TextIndex, start: number, end: number ): AbstractRange {
	const $startNode = index.$nodes[ index.piece[ start ] ];
	const $endNode = index.$nodes[ index.endPiece[ end - 1 ] ];
	const startOffset = index.from[ start ];
	const endOffset = index.to[ end - 1 ];
	if ( $startNode.getRootNode() === $endNode.getRootNode() ) {
		const range = ( $startNode.ownerDocument ?? document ).createRange();
		range.setStart( $startNode, startOffset );
		range.setEnd( $endNode, endOffset );
		return range;
	}
	return new StaticRange( { startContainer: $startNode, startOffset, endContainer: $endNode, endOffset } );
}

/**
 * Describes the passage a range covers, as the shape a note stores.
 *
 * Call it with the range of the user's selection; the result is plain data, ready for `JSON.stringify()`.
 *
 * @param $root - The element that holds the content.
 * @param range - The passage.
 * @param options - What the app says about its markup, the same options `locate()` gets.
 * @returns The stored shape, or null when the range is outside the content or holds only whitespace.
 */
export function describe( $root: Node, range: AbstractRange, options: AnchorOptions = {} ): StoredAnchor | null {
	const index = buildTextIndex( $root, options );
	const span = spanOf( index, range );
	return span ? describeSpan( index, span.start, span.end ) : null;
}

/**
 * Looks for a stored passage in the content as it is now, and gives the note its answer.
 *
 * It builds a new index and new ranges on every call, because a range made for an older version of the page points at the wrong characters or at nodes that are gone.
 *
 * @param $root - The element that holds the content.
 * @param stored - What the note stored.
 * @param options - What the app says about its markup, the same options `describe()` got.
 * @returns The answer, fresh ranges for the chosen places and the guess, and every candidate with its signals.
 */
export function locate( $root: Node, stored: StoredAnchor, options: AnchorOptions = {} ): Location {
	const index = buildTextIndex( $root, options );
	const match = findPassage( index, stored );
	return {
		...match,
		ranges: match.chosen.map( ( candidate ) => rangeFor( index, candidate.start, candidate.end ) ),
		guessRange: match.guess ? rangeFor( index, match.guess.start, match.guess.end ) : null,
	};
}

/**
 * Checks that a value read back from storage has the stored shape, before the app uses it.
 *
 * @param value - The parsed JSON.
 * @returns Whether it's a stored anchor.
 */
export function isStoredAnchor( value: unknown ): value is StoredAnchor {
	return typeof value === 'object' && value !== null
		&& 'start' in value && typeof value.start === 'number'
		&& 'end' in value && typeof value.end === 'number'
		&& 'exact' in value && typeof value.exact === 'string'
		&& 'prefix' in value && typeof value.prefix === 'string'
		&& 'suffix' in value && typeof value.suffix === 'string'
		&& ( !( 'wholeBlock' in value ) || value.wholeBlock === true )
		&& ( !( 'copies' in value ) || ( Number.isInteger( value.copies ) && Number( value.copies ) > 1 ) );
}
The panel shows the code that puts the notes back on the right words. The docs page, the margin, and the version menu around it are only there for the demo.

Checking other libraries

I ran the demo with the green note on Chrome’s text fragments, four annotation libraries, and the solution we coded in this article.

Table Where each library put the green note after each release.
Library1.1 New paragraph1.2 Tip in Quick start1.3 Seven became five1.4 Paragraph removed
Chrome’s text fragmentsRight sentenceThe Quick start copyThe Quick start copyThe Quick start copy
HypothesisRight sentenceRight sentenceThe Quick start copyThe Quick start copy
Apache Annotator (retired)Right sentenceTwo matches, left to the appThe Quick start copyThe Quick start copy
Recogito text annotatorOther wordsOther wordsOther wordsOther words
web-highlighterOther wordsOther wordsOther wordsOther words
This article’s notesRight sentenceRight sentenceRight sentence, marked Text changedNot placed, Quick start copy offered
Tested in Playwright’s Chromium 153, Firefox 155, and WebKit 26.6, three runs each with identical results, using text-fragments-polyfill 6.7.0, Hypothesis’s anchoring code at commit a7b7400d, Apache Annotator 0.2.0, Recogito’s text annotator 4.3.7, and web-highlighter 0.8.0.

None of the tools reported an error when they put the note on the wrong words. Text fragments, Hypothesis, Recogito, and web-highlighter always returned one place, even when it was the wrong one. Apache Annotator returned two matches on 1.2, so the app could tell the note had two possible places. On 1.3 and 1.4, it returned just the Quick start copy.

Recogito and web-highlighter let you run your own function when they restore a note. In Recogito, that’s selectorReviveFn. In web-highlighter, it’s the Serialize.Restore hook. There, you can compare the words the library found with the words you saved and drop the note if they don’t match.

A few ways this goes wrong

Counting characters one way to save a note and another way to restore it puts the note on other words. The green sentence, for example, starts at 590 in the index and at 614 in the docs page’s textContent. The difference is extra whitespace from the HTML source, which the index doesn’t count.

Once a StaticRange’s Text node gets shorter than its offsets, copying it into a live range throws an IndexSizeError. After a change, build fresh ranges from the saved description rather than keep the old ones.

What separates a senior from a junior here

  • Notes made and restored on one version always look right. Put a later version of the page in the test fixtures and check all the notes on it.
  • An exact copy of the words somewhere else is a different passage. Check that the code scores the words and the text around them separately.
  • A note that can’t be placed is a result the user can act on. Make sure it gets a card with the saved words rather than a highlight on other text.
  • Keyboard and screen reader users read and open notes through the margin. Test the margin with both.

Want more of these in your Google results?