├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── karma.conf.js ├── package.json ├── src ├── index.js └── range-to-string.js └── test ├── fixtures └── test.html └── index.spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | coverage 3 | lib 4 | node_modules 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .travis.yml 3 | coverage 4 | karma.conf.js 5 | src 6 | test 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - stable 5 | after_success: 6 | - bash <(curl -s https://codecov.io/bash) 7 | cache: 8 | directories: 9 | - node_modules 10 | notifications: 11 | email: false 12 | irc: 13 | channels: 14 | - chat.freenode.net#annotator 15 | on_success: change 16 | on_failure: change 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Randall Leeds 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Text Position Anchor 2 | ==================== 3 | 4 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](http://opensource.org/licenses/MIT) 5 | [![NPM Package](https://img.shields.io/npm/v/dom-anchor-text-position.svg)](https://www.npmjs.com/package/dom-anchor-text-position) 6 | [![Build Status](https://travis-ci.org/tilgovi/dom-anchor-text-position.svg?branch=master)](https://travis-ci.org/tilgovi/dom-anchor-text-position) 7 | [![codecov](https://img.shields.io/codecov/c/github/tilgovi/dom-anchor-text-position/master.svg)](https://codecov.io/gh/tilgovi/dom-anchor-text-position) 8 | 9 | This library offers conversion between a DOM `Range` instance and a text 10 | position selector as defined by the Web Annotation Data Model. 11 | 12 | For more information on `Range` see 13 | [the documentation](https://developer.mozilla.org/en-US/docs/Web/API/Range). 14 | 15 | For more information on the text position selector see 16 | [the specification](http://www.w3.org/TR/annotation-model/#text-position-selector). 17 | 18 | Installation 19 | ============ 20 | 21 | To `require('dom-anchor-text-position')`: 22 | 23 | npm install dom-anchor-text-position 24 | 25 | Usage 26 | ===== 27 | 28 | ## API Documentation 29 | 30 | The module exposes only two functions. 31 | 32 | ### `fromRange(root, range)` 33 | 34 | Provided with an existing `Range` instance this will return an object that 35 | stores the offsets of the beginning and end of the text selected by the range as 36 | measured from the beginning of the `root` `Node`. 37 | 38 | ### `toRange(root, selector = {start, end})` 39 | 40 | This method returns a `Range` object that covers the interval `[start, end)` of 41 | the text content of the `root` `Node`. 42 | 43 | If the end is not provided, returns a collapsed range. If the start is not 44 | provided, the default is `0`. 45 | 46 | If the `start` or `end` offsets are outside the range of valid character offsets 47 | within the text content of the `root` node, an exception is thrown. 48 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib') 2 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | browsers: ['PhantomJS'], 4 | browserify: {debug: true, transform: ['babelify']}, 5 | frameworks: ['browserify', 'chai', 'fixture', 'mocha'], 6 | files: [ 7 | 'test/*.js', 8 | 'test/fixtures/*.html' 9 | ], 10 | preprocessors: { 11 | 'test/*.js': ['browserify'], 12 | 'test/fixtures/*.html': ['html2js'] 13 | }, 14 | reporters: ['progress', 'coverage'], 15 | coverageReporter: { 16 | reporters: [ 17 | {type: 'html', subdir: '.'}, 18 | {type: 'json', subdir: '.'}, 19 | {type: 'lcovonly', subdir: '.'}, 20 | {type: 'text', subdir: '.'} 21 | ] 22 | } 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dom-anchor-text-position", 3 | "version": "5.0.0", 4 | "description": "Convert between DOM Range instances and text positions.", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "babel src -d lib -s", 8 | "test": "eslint src test && BABEL_ENV=test nyc mocha", 9 | "prepublish": "yarn run build" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/tilgovi/dom-anchor-text-position.git" 14 | }, 15 | "keywords": [ 16 | "dom", 17 | "anchor", 18 | "range", 19 | "text", 20 | "position" 21 | ], 22 | "author": "Randall Leeds ", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/tilgovi/dom-anchor-text-position/issues" 26 | }, 27 | "homepage": "https://github.com/tilgovi/dom-anchor-text-position", 28 | "dependencies": { 29 | "dom-seek": "^5.1.0" 30 | }, 31 | "devDependencies": { 32 | "@babel/cli": "^7.8.4", 33 | "@babel/core": "^7.9.0", 34 | "@babel/preset-env": "^7.9.0", 35 | "@babel/register": "^7.9.0", 36 | "@istanbuljs/nyc-config-babel": "^3.0.0", 37 | "babel-eslint": "^10.1.0", 38 | "babel-plugin-istanbul": "^6.0.0", 39 | "chai": "^4.2.0", 40 | "eslint": "^6.8.0", 41 | "jsdom": "^16.2.1", 42 | "jsdom-global": "^3.0.2", 43 | "mocha": "^7.1.1", 44 | "nyc": "^15.0.0" 45 | }, 46 | "babel": { 47 | "env": { 48 | "test": { 49 | "plugins": [ 50 | "istanbul" 51 | ] 52 | } 53 | }, 54 | "presets": [ 55 | "@babel/preset-env" 56 | ] 57 | }, 58 | "eslintConfig": { 59 | "env": { 60 | "browser": true 61 | }, 62 | "extends": "eslint:recommended", 63 | "parser": "babel-eslint", 64 | "rules": { 65 | "comma-dangle": 0 66 | }, 67 | "overrides": [ 68 | { 69 | "files": [ 70 | "test/**/*.js" 71 | ], 72 | "env": { 73 | "mocha": true, 74 | "node": true 75 | } 76 | } 77 | ] 78 | }, 79 | "mocha": { 80 | "require": [ 81 | "jsdom-global/register" 82 | ] 83 | }, 84 | "nyc": { 85 | "extends": "@istanbuljs/nyc-config-babel", 86 | "reporter": [ 87 | "html", 88 | "lcov", 89 | "text" 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import seek from 'dom-seek' 2 | 3 | import rangeToString from './range-to-string' 4 | 5 | const SHOW_TEXT = 4 6 | 7 | export function fromRange(root, range) { 8 | if (root === undefined) { 9 | throw new Error('missing required parameter "root"') 10 | } 11 | if (range === undefined) { 12 | throw new Error('missing required parameter "range"') 13 | } 14 | 15 | let document = root.ownerDocument 16 | let prefix = document.createRange() 17 | 18 | let startNode = range.startContainer 19 | let startOffset = range.startOffset 20 | 21 | prefix.setStart(root, 0) 22 | prefix.setEnd(startNode, startOffset) 23 | 24 | let start = rangeToString(prefix).length 25 | let end = start + rangeToString(range).length 26 | 27 | return { 28 | start: start, 29 | end: end, 30 | } 31 | } 32 | 33 | 34 | export function toRange(root, selector = {}) { 35 | if (root === undefined) { 36 | throw new Error('missing required parameter "root"') 37 | } 38 | 39 | const document = root.ownerDocument 40 | const range = document.createRange() 41 | const iter = document.createNodeIterator(root, SHOW_TEXT) 42 | 43 | const start = selector.start || 0 44 | const end = selector.end || start 45 | 46 | const startOffset = start - seek(iter, start); 47 | const startNode = iter.referenceNode; 48 | 49 | const remainder = end - start + startOffset; 50 | 51 | const endOffset = remainder - seek(iter, remainder); 52 | const endNode = iter.referenceNode; 53 | 54 | range.setStart(startNode, startOffset) 55 | range.setEnd(endNode, endOffset) 56 | 57 | return range 58 | } 59 | -------------------------------------------------------------------------------- /src/range-to-string.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Return the next node after `node` in a tree order traversal of the document. 3 | */ 4 | function nextNode(node, skipChildren) { 5 | if (!skipChildren && node.firstChild) { 6 | return node.firstChild 7 | } 8 | 9 | do { 10 | if (node.nextSibling) { 11 | return node.nextSibling 12 | } 13 | node = node.parentNode 14 | } while (node) 15 | 16 | /* istanbul ignore next */ 17 | return node 18 | } 19 | 20 | function firstNode(range) { 21 | if (range.startContainer.nodeType === Node.ELEMENT_NODE) { 22 | const node = range.startContainer.childNodes[range.startOffset] 23 | return node || nextNode(range.startContainer, true /* skip children */) 24 | } 25 | return range.startContainer 26 | } 27 | 28 | function firstNodeAfter(range) { 29 | if (range.endContainer.nodeType === Node.ELEMENT_NODE) { 30 | const node = range.endContainer.childNodes[range.endOffset] 31 | return node || nextNode(range.endContainer, true /* skip children */) 32 | } 33 | return nextNode(range.endContainer) 34 | } 35 | 36 | function forEachNodeInRange(range, cb) { 37 | let node = firstNode(range) 38 | const pastEnd = firstNodeAfter(range) 39 | while (node !== pastEnd) { 40 | cb(node) 41 | node = nextNode(node) 42 | } 43 | } 44 | 45 | /** 46 | * A ponyfill for Range.toString(). 47 | * Spec: https://dom.spec.whatwg.org/#dom-range-stringifier 48 | * 49 | * Works around the buggy Range.toString() implementation in IE and Edge. 50 | * See https://github.com/tilgovi/dom-anchor-text-position/issues/4 51 | */ 52 | export default function rangeToString(range) { 53 | // This is a fairly direct translation of the Range.toString() implementation 54 | // in Blink. 55 | let text = '' 56 | forEachNodeInRange(range, (node) => { 57 | if (node.nodeType !== Node.TEXT_NODE) { 58 | return 59 | } 60 | const start = node === range.startContainer ? range.startOffset : 0 61 | const end = node === range.endContainer ? range.endOffset : node.textContent.length 62 | text += node.textContent.slice(start, end) 63 | }) 64 | return text 65 | } 66 | 67 | -------------------------------------------------------------------------------- /test/fixtures/test.html: -------------------------------------------------------------------------------- 1 |

Pellentesque habitant morbi tristique senectus et netus et 2 | malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, 3 | ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas 4 | semper. Aenean ultricies mi vitae est. Mauris placerat eleifend 5 | leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat 6 | wisi, condimentum sed, commodo vitae, ornare sit amet, 7 | wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum 8 | orci, sagittis tempus lacus enim ac dui. Donec non enim in 9 | turpis pulvinar facilisis. Ut felis.

10 |
11 |

Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, 12 | eu vulputate magna eros eu erat.

13 | -------------------------------------------------------------------------------- /test/index.spec.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import fs from 'fs' 3 | import path from 'path' 4 | import {fromRange, toRange} from '../src' 5 | 6 | let fixturePath = path.join(__dirname, 'fixtures', 'test.html') 7 | let fixtureBuf = fs.readFileSync(fixturePath) 8 | let fixtureHTML = fixtureBuf.toString().trim() 9 | 10 | 11 | describe('textPosition', () => { 12 | let fixture = null 13 | 14 | before(() => { 15 | fixture = { el: document.createElement('div') } 16 | fixture.el.innerHTML = fixtureHTML 17 | }); 18 | 19 | describe('fromRange', () => { 20 | it('requires a root argument', () => { 21 | let construct = () => fromRange() 22 | assert.throws(construct, 'required parameter') 23 | }) 24 | 25 | it('requires a range argument', () => { 26 | let construct = () => fromRange(fixture.el) 27 | assert.throws(construct, 'required parameter') 28 | }) 29 | 30 | it('can describe a whole, single text node', () => { 31 | let root = fixture.el 32 | let range = document.createRange() 33 | let codeNode = root.querySelector('code') 34 | let textNode = codeNode.childNodes[0] 35 | range.selectNodeContents(textNode) 36 | let anchor = fromRange(root, range) 37 | let {start, end} = anchor 38 | let text = root.textContent.substr(start, end - start) 39 | assert.equal(text, 'commodo vitae') 40 | }) 41 | 42 | it('can describe part of a single text node', () => { 43 | let root = fixture.el 44 | let range = document.createRange() 45 | let codeNode = root.querySelector('code') 46 | let textNode = codeNode.childNodes[0] 47 | range.setStart(textNode, 5) 48 | range.setEnd(textNode, 12) 49 | let anchor = fromRange(root, range) 50 | let {start, end} = anchor 51 | let text = root.textContent.substr(start, end - start) 52 | assert.equal(text, 'do vita') 53 | }) 54 | 55 | it('can describe a range from one text node to another', () => { 56 | let root = fixture.el 57 | let range = document.createRange() 58 | let emNode = root.querySelector('em') 59 | let emTextNode = emNode.childNodes[0] 60 | let codeNode = root.querySelector('code') 61 | let codeTextNode = codeNode.childNodes[0] 62 | range.setStart(emTextNode, 7) 63 | range.setEnd(codeTextNode, 7) 64 | let anchor = fromRange(root, range) 65 | let {start, end} = anchor 66 | let text = root.textContent.substr(start, end - start) 67 | let expected = [ 68 | 'ultricies mi vitae est.', 69 | ' Mauris placerat eleifend\n leo. Quisque sit amet est', 70 | ' et sapien ullamcorper pharetra. Vestibulum erat\n', 71 | ' wisi, condimentum sed, commodo', 72 | ].join('') 73 | assert.equal(text, expected) 74 | }) 75 | 76 | it('can describe a whole, single element', () => { 77 | let root = fixture.el 78 | let range = document.createRange() 79 | let node = root.querySelector('code') 80 | range.selectNodeContents(node) 81 | let anchor = fromRange(root, range) 82 | let {start, end} = anchor 83 | let text = root.textContent.substr(start, end - start) 84 | assert.equal(text, 'commodo vitae') 85 | }) 86 | 87 | it('can describe a range between elements', () => { 88 | let root = fixture.el 89 | let range = document.createRange() 90 | let emNode = root.querySelector('em') 91 | let codeNode = root.querySelector('code') 92 | range.setStartBefore(emNode) 93 | range.setEndAfter(codeNode) 94 | let anchor = fromRange(root, range) 95 | let {start, end} = anchor 96 | let text = root.textContent.substr(start, end - start) 97 | let expected = [ 98 | 'Aenean ultricies mi vitae est.', 99 | ' Mauris placerat eleifend\n leo. Quisque sit amet est', 100 | ' et sapien ullamcorper pharetra. Vestibulum erat\n', 101 | ' wisi, condimentum sed, commodo vitae', 102 | ].join('') 103 | assert.equal(text, expected) 104 | }) 105 | 106 | it('can describe a range between an element and a text node', () => { 107 | let root = fixture.el 108 | let range = document.createRange() 109 | let emNode = root.querySelector('em') 110 | let codeNode = root.querySelector('code') 111 | let codeTextNode = codeNode.childNodes[0] 112 | range.setStartBefore(emNode) 113 | range.setEnd(codeTextNode, 7) 114 | let anchor = fromRange(root, range) 115 | let {start, end} = anchor 116 | let text = root.textContent.substr(start, end - start) 117 | let expected = [ 118 | 'Aenean ultricies mi vitae est.', 119 | ' Mauris placerat eleifend\n leo. Quisque sit amet est', 120 | ' et sapien ullamcorper pharetra. Vestibulum erat\n', 121 | ' wisi, condimentum sed, commodo', 122 | ].join('') 123 | assert.equal(text, expected) 124 | }) 125 | 126 | it('can describe a range between a text node and an element', () => { 127 | let root = fixture.el 128 | let range = document.createRange() 129 | let emNode = root.querySelector('em') 130 | let emTextNode = emNode.childNodes[0] 131 | let codeNode = root.querySelector('code') 132 | range.setStart(emTextNode, 7) 133 | range.setEndAfter(codeNode, 7) 134 | let anchor = fromRange(root, range) 135 | let {start, end} = anchor 136 | let text = root.textContent.substr(start, end - start) 137 | let expected = [ 138 | 'ultricies mi vitae est.', 139 | ' Mauris placerat eleifend\n leo. Quisque sit amet est', 140 | ' et sapien ullamcorper pharetra. Vestibulum erat\n', 141 | ' wisi, condimentum sed, commodo vitae', 142 | ].join('') 143 | assert.equal(text, expected) 144 | }) 145 | 146 | it('can describe a range starting at an empty element', () => { 147 | let root = fixture.el 148 | let range = document.createRange() 149 | let hrEl = root.querySelector('hr') 150 | range.setStart(hrEl, 0) 151 | range.setEnd(hrEl.nextSibling.nextSibling.firstChild, 16) 152 | let {start, end} = fromRange(root, range) 153 | let text = root.textContent.substr(start, end - start) 154 | assert.equal(text, '\nPraesent dapibus') 155 | }); 156 | 157 | it('can describe a range ending at an empty element', () => { 158 | let root = fixture.el 159 | let range = document.createRange() 160 | let hrEl = root.querySelector('hr') 161 | let prevText = hrEl.previousSibling.previousSibling.lastChild 162 | range.setStart(prevText, prevText.textContent.length - 9) 163 | range.setEnd(hrEl, 0) 164 | let {start, end} = fromRange(root, range) 165 | let text = root.textContent.substr(start, end - start) 166 | assert.equal(text, 'Ut felis.\n') 167 | }); 168 | 169 | it('can describe a range beginning at the end of a non-empty element', () => { 170 | let root = fixture.el 171 | let range = document.createRange() 172 | let strongEl = root.querySelector('strong') 173 | range.setStart(strongEl, 1) 174 | range.setEnd(strongEl.nextSibling, 9) 175 | let {start, end} = fromRange(root, range) 176 | let text = root.textContent.substr(start, end - start) 177 | assert.equal(text, ' senectus') 178 | }); 179 | 180 | it('can describe a collapsed range', () => { 181 | let root = fixture.el 182 | let range = document.createRange() 183 | let strongEl = root.querySelector('strong') 184 | range.setStart(strongEl.firstChild, 10) 185 | range.setEnd(strongEl.firstChild, 5) 186 | let {start, end} = fromRange(root, range) 187 | let text = root.textContent.substr(start, end - start) 188 | assert.equal(text, '') 189 | }); 190 | }) 191 | 192 | describe('toRange', () => { 193 | it('requires a root argument', () => { 194 | let construct = () => toRange() 195 | assert.throws(construct, 'required parameter') 196 | }) 197 | 198 | it('returns a range selecting a whole text node', () => { 199 | let root = fixture.el 200 | let expected = 'commodo vitae' 201 | let start = root.textContent.indexOf(expected) 202 | let end = start + expected.length 203 | let range = toRange(root, {start, end}) 204 | let text = range.toString() 205 | assert.equal(text, expected) 206 | }) 207 | 208 | it('returns a range selecting part of a text node', () => { 209 | let root = fixture.el 210 | let expected = 'do vit' 211 | let start = root.textContent.indexOf(expected) 212 | let end = start + expected.length 213 | let range = toRange(root, {start, end}) 214 | let text = range.toString() 215 | assert.equal(text, expected) 216 | }) 217 | 218 | it('returns a range selecting part of multiple text nodes', () => { 219 | let root = fixture.el 220 | let expected = 'do vitae, ornare' 221 | let start = root.textContent.indexOf(expected) 222 | let end = start + expected.length 223 | let range = toRange(root, {start, end}) 224 | let text = range.toString() 225 | assert.equal(text, expected) 226 | }) 227 | 228 | it('defaults to a collapsed range', () => { 229 | let range = toRange(fixture.el) 230 | assert.isTrue(range.collapsed) 231 | }) 232 | 233 | it('returns a range selecting the first text of the root element', () => { 234 | let root = fixture.el 235 | let expected = 'Pellentesque' 236 | let start = 0 237 | let end = expected.length 238 | let range = toRange(root, {start, end}) 239 | let text = range.toString() 240 | assert.equal(text, expected) 241 | }) 242 | 243 | it('returns a range selecting the last text of the root element', () => { 244 | let root = fixture.el 245 | let expected = 'erat.' 246 | let end = root.textContent.length 247 | let start = end - 5 248 | let range = toRange(root, {start, end}) 249 | let text = range.toString() 250 | assert.equal(text, expected) 251 | }) 252 | 253 | it('returns an empty range selecting the end of the root element', () => { 254 | let root = fixture.el 255 | let end = root.textContent.length 256 | let range = toRange(root, {start: end, end}) 257 | let text = range.toString() 258 | assert.equal(text, '') 259 | }) 260 | 261 | it('throws an error if the start offset is out of range', () => { 262 | let root = fixture.el 263 | assert.throws(() => { 264 | let start = 10000 265 | let end = start + 1 266 | toRange(root, {start, end}) 267 | }, RangeError) 268 | }) 269 | 270 | it('throws an error if the end offset is out of range', () => { 271 | let root = fixture.el 272 | assert.throws(() => { 273 | let start = 0 274 | let end = 10000 275 | toRange(root, {start, end}) 276 | }, RangeError) 277 | }) 278 | 279 | it('handles an empty root element', () => { 280 | let root = document.createElement('div'); 281 | 282 | // This case throws to preserve the invariant that the returned `Range` 283 | // always has a text node as the `startContainer` and `endContainer`. 284 | assert.throws(() => { 285 | toRange(root, {start:0, end: 0}) 286 | }, RangeError); 287 | }) 288 | 289 | it('handles a root element with an empty text node', () => { 290 | let root = document.createElement('div'); 291 | root.appendChild(document.createTextNode('')) 292 | let range = toRange(root, {start:0, end: 0}) 293 | assert.equal(range.toString(), '') 294 | }) 295 | 296 | it('handles an `end` offset less than the `start` offset', () => { 297 | let root = fixture.el 298 | let expected = 'do vit' 299 | let start = root.textContent.indexOf(expected) 300 | let end = start + expected.length 301 | let range = toRange(root, {start: end, end: start}) 302 | let text = range.toString() 303 | 304 | // This case could reasonably throw or simply return a collapsed range. 305 | // It returns a collapsed range as that seems like it would be more 306 | // convenient for the caller. 307 | assert.equal(text, '') 308 | }) 309 | }) 310 | }) 311 | --------------------------------------------------------------------------------