├── .ackrc ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── calorimetre.js ├── index.html ├── karma.conf-ci.js ├── karma.conf.js ├── package-lock.json ├── package.json ├── src └── index.js └── test ├── _utils.js ├── child-offsets.js ├── create-hash.js ├── create-key.js ├── find-key.js ├── get-scope.js ├── normalize-offset.js ├── normalize-text.js ├── offsets.js ├── range-from-text.js ├── range-offsets.js └── serialize-range.js /.ackrc: -------------------------------------------------------------------------------- 1 | --ignore-dir=static 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | bundle.* 31 | scratch/ 32 | sauce.json 33 | dist 34 | web-verse.min.js 35 | web-verse.js 36 | coverage/ 37 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | scratch 4 | .ackrc 5 | .babelrc 6 | .eslintrc 7 | .gitignore 8 | circle.yml 9 | .travis.yml 10 | index.html 11 | sauce.json 12 | coverage/ 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web Verse 2 | 3 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 4 | 5 | Web Verse enables deep-linking into HTML text, without requiring specific coöperation from the 6 | content (such as adding `id` attributes everywhere). It can be used to generate locator keys for 7 | content inside of a page that are reasonably resilient to markup modifications as well as to edits 8 | to the text itself. As such, it can be used to build an annotation system for text that is likely 9 | to be edited over time. Obviously it is not 10 | [altogether unstoppable][no-power-in-the-verse] but it offers good enough resilience to 11 | be used in production systems. 12 | 13 | It was inspired by [Emphasis][nyt] by Michael Donohoe and [Ted Nelson parallel markup](https://www.xml.com/pub/a/w3j/s3.nelson.html), but leverages the [Range interface][ranges] 14 | and [selection object][selections]. 15 | 16 | We do not provide direct support for instance for mapping a URL's hash containing a Web Verse key 17 | into a specific paragraph or the such. Rather, the expectation is that one can build one's own 18 | preferred annotation system (or more generally deep, resilient linking system) very easily on top 19 | of Web Verse. 20 | 21 | We fingerprint a block-level element (e.g. a paragraph) by: 22 | 23 | 1. Normalising the text to abstract away from markup and formatting differences. 24 | 25 | 2. Breaking the text into sentences. We attempt to be smart about handling full-stops. We'll ignore 26 | things like "Dr. Who" and a number of similar cases. It is generally enough to avoid getting 27 | single word nonsense for our sentences. 28 | 3. Taking the first and last sentences. It's OK if the first and last sentences are the same, the 29 | key is still meaningful. 30 | 4. Taking the first character from the first three words of each sentence. Words are defined as 31 | tokens composed of a run of non-white-space characters. 32 | 33 | These fingerprints [have been shown][jsconf] to provide reasonable uniqueness for reasonably-sized 34 | documents. Since it's deterministic yet not dependent on all the content, this method is tolerant to 35 | smaller changes in the content. Furthermore, finding keys can take edit-distance into account, which 36 | enables additional resilience to change. 37 | 38 | Regions of text more specific than a block-level element can be referenced from within a block using 39 | character ranges. For instance, in the following paragraph: 40 | 41 | 42 | `I` `a`m `a` paragraph with 2 **sentences**. 43 | `I` `a`m `t`he second sentence. 44 | 45 | 46 | We can refer to the word `sentences` in the first sentence by using the range, `24-33`. Altogether 47 | with the paragraph's fingerprint, this gives us an address selecting just that word of 48 | `IaaIat:25-33`. (Note that the text offsets are zero-based, and apply to normalised text.) 49 | 50 | ## Installation 51 | 52 | `npm install web-verse` 53 | 54 | ### In the browser 55 | 56 | This is primarily a client-side library (~7k minizipped), just include the `web-verse.min.js` script 57 | that comes with the distribution. 58 | 59 | ### In Node 60 | 61 | Web Verse works with Node, but you have to bring your own DOM. Currently, the best option is likely 62 | to be `jsdom`, but it has limitations due to it not supporting `Range`s. 63 | 64 | The following subset of methods works with Node and `jsdom`: 65 | 66 | * `createKey()` 67 | * `createHash()` 68 | * `getScope()`, but only with a `node` argument 69 | * `serializeNode()` 70 | * `findKey()` 71 | * `getChildOffsets()` 72 | * `normalizeText()` 73 | * `normalizeOffset()` 74 | * `denormalizeOffset()` 75 | 76 | These should normally be more than enough to carry out the sort of operations that you are likely to 77 | want to do on the server (as opposed to, say, getting the user's selection and producing a link from 78 | it). 79 | 80 | ## API 81 | 82 | When loaded in a Web context, Web Verse exposes itself as a global `WebVerse` object, on which the 83 | following methods are available. 84 | 85 | ### `key = WebVerse.createKey($el)` 86 | 87 | Given an element, returns a 6-char key that summarises it for the purposes of deep, resilient 88 | linking. 89 | 90 | ### `result = WebVerse.findKey(targetKey, candidateKeys)` 91 | 92 | Given a key that is being searched for, and a list of candidate keys (for instance, all the keys for 93 | block elements in the document), this will return the best match it can find. 94 | 95 | The returned object has fields for `index` (the index in `candidateKeys` that best matched), `value` 96 | (the value that actually matched, which may differ slightly from the `targetKey`), and `lev` (an 97 | indication of the Levenshtein edit distance of the match). If no match was found, all of those 98 | fields will be `undefined`. 99 | 100 | The match works by first attempting an exact match, then by choosing the candidate with the smallest 101 | edit distance. No edit distance can be greater than or equal to 3. 102 | 103 | ### `hash = WebVerse.createHash($el)` 104 | 105 | Given an element, it will return a hash for it that is invariant to numerous markup changes inside 106 | of it, looking only at its normalised text content. Such a hash can also be used to generate 107 | resilient identifiers. 108 | 109 | ### `details = WebVerse.serializeRange(range, $el)` 110 | 111 | Given a range and optionally a scoping element (which defaults to `getScope(range)`), it will return 112 | the details one needs in order to create a resilient pointer to that range. The returned object 113 | contains: 114 | 115 | * `$scope`: The scoping element (which was used for key and hash generation). 116 | * `hash`: The hash of the scoping element, can be used as an ID that is resilient to markup and 117 | white space changes but not to text edits. 118 | * `key`: The key for the scoping element; can *also* be used as an ID. It is resilient to markup and 119 | white space changes, as well as to a certain amount of text editing; but it is less unique than 120 | the `hash`. 121 | * `startOffset`, `endOffset`: The normalised offsets into the text for that range. 122 | 123 | So if you were to wish to use the key+offsets fingerpint that is discussed in this README's 124 | introduction in order to obtain a resilient pointer into what a given range captures, you would: 125 | 126 | ```js 127 | var details = WebVerse.serializeRange(range); 128 | var fingerprint = details. key + ':' + details.startOffset + '-' + details.endOffset; 129 | ``` 130 | 131 | ### `details = WebVerse.serializeSelection()` 132 | 133 | Returns the same details as `serializeRange()` but for the current selection. If there is no 134 | selection (or if it is collapsed) it returns `undefined`. 135 | 136 | ### `details = WebVerse.serializeNode($node, $el)` 137 | 138 | The same as `serializeRange()` but instead of a `Range` it uses a node, taking its own text content 139 | as the offsets into the given scope. If no scoping `$el` is given, it will use `getScope($node)`. 140 | 141 | ### `range = WebVerse.rangeFromOffsets($scope, startOffset, endOffset)` 142 | 143 | Given a scope and normalised start/end offsets (that you may have stored in a fingerprint), returns 144 | a `range` object suitable to use directly on the DOM (i.e. applying to the raw content). 145 | 146 | If you start with a fingerprint such as the `IaaIat:24-33` example you would use the `IaaIat` part 147 | to find the `$scope` (typically with `findKey()`) and then this method using the scope and the 148 | offsets. It returns a `Range` that you could wrap to highlight, etc. 149 | 150 | ### `ranges = WebVerse.getRangesFromText($el, searchText)` 151 | 152 | Given an element to scope the search in, and a string, it will find all instances of that string 153 | (in a normalised, white-space-invariant manner) inside the textual content of that element, and 154 | return an array of `Range` elements pointing into the matches. 155 | 156 | This can be used to find an highlight a specific string. Or, for instance, if a user is creating a 157 | link around a given string in a text this can offer the option of linking all other occurrences of 158 | the same string. 159 | 160 | Since it returns `Range`s, it can be easily used with [`Range.surroundContents`][surround-contents]. 161 | 162 | ### `WebVerse.citeable` 163 | 164 | An array of element `tagName`s (i.e. uppercase) that are considered acceptable scopes (block-level 165 | elements). You can modify this to alter Web Verse's behaviour. 166 | 167 | ### `text = WebVerse.normalizeText(text)` 168 | 169 | Given a string, returns a version normalised according to Web Verse's internal normalising 170 | algorithm. This is essentially `str.trim().replace(/\s+/g, ' ')` but with its behaviour made 171 | resistant to browser vagaries. 172 | 173 | ### `offset = WebVerse.normalizeOffset(rawOffset, rawText)` 174 | 175 | Web Verse hides away a lot of the complexity involved in dealing with normalised text internally but 176 | having to manipulate a DOM that has raw, unnormalised text content (obviously, without changing the 177 | DOM). 178 | 179 | This method returns the offset in the normalised text equivalent to the given raw offset into the 180 | unadulterated text. So calling it with `4, ' a b'` (which has the offset right before the `b`) will 181 | return `2`, since the normalised text is `a b`. 182 | 183 | This may seem cryptic, and in many ways it is. You should only need this if you are trying to 184 | manipulate the text in the same manner as Web Verse does, for instance to extend its functionality. 185 | 186 | ### `offset = WebVerse.denormalizeOffset(normalisedOffset, rawText)` 187 | 188 | Does the reverse of the previous one: given a normalised offset and the *raw* text, it will return 189 | the matching raw offset. 190 | 191 | ### `$element = WebVerse.getScope(range|$node)` 192 | 193 | Given a range or a `$node`, will return the closest enclosing element that may scope it (i.e. a 194 | block-level element from `citeable`). This can the range's `commonAncestorContainer` or any of its 195 | parents. If it goes up the tree without finding a valid candidate, it will return `undefined`. 196 | 197 | ### `details = WebVerse.getOffsets(range, $el)` 198 | 199 | Given a range and an element scope, return an object with `startOffset` and `endOffset` that are the 200 | offsets into the normalised text equivalent to that range, for that scope. Mostly of internal use. 201 | 202 | ### `details = WebVerse.getChildOffsets($parent, $child)` 203 | 204 | Same as `getOffsets()` but uses a `$child` text node (or a `$child` element containing text) as 205 | determining the offsets inside a `$parent` element. Returns `startOffset` and `endOffset` fields 206 | being the offsets normalised to the content of the `$parent`. 207 | 208 | 209 | ## Development 210 | 211 | The best thing when developing is to `npm run watch`. This will build both Node and browser versions 212 | continuously. It is also a good idea to `npm run test-local`, which will keep the Karma tests 213 | running (just in Chrome, so as not to be too invasive) whenever you make changes. 214 | 215 | --- 216 | [jsconf]: http://2014.jsconf.eu/speakers/michael-donohoe-deeplink-to-anything-on-the-web.html 217 | [nyt]: https://github.com/NYTimes/Emphasis 218 | [ranges]: https://developer.mozilla.org/en-US/docs/Web/API/Range 219 | [selections]: https://developer.mozilla.org/en-US/docs/Web/API/Selection 220 | [surround-contents]: https://developer.mozilla.org/en-US/docs/Web/API/Range/surroundContents 221 | [no-power-in-the-verse]: https://youtu.be/uRdbEY_YfV4?t=24s 222 | -------------------------------------------------------------------------------- /calorimetre.js: -------------------------------------------------------------------------------- 1 | 2 | // a quick and dirty tool to know what is taking up space in a bundle 3 | // this should be made generic 4 | var exec = require('child_process').exec 5 | , chalk = require('chalk') 6 | , entryFile = 'src/index.js' 7 | ; 8 | exec(`browserify --deps ${entryFile}`, function (err, stdout, stderr) { 9 | var files = {} 10 | , incBy = {} 11 | , deps = JSON.parse(stdout) 12 | ; 13 | deps.forEach(function (dep) { 14 | files[dep.file] = { 15 | size: dep.source.replace(/^\/\/# sourceMap.*/m, '').length 16 | , entry: !!dep.entry 17 | }; 18 | for (var k in dep.deps) { 19 | var includes = dep.deps[k]; 20 | if (!incBy[includes]) incBy[includes] = {}; 21 | incBy[includes][dep.file] = true; 22 | } 23 | }); 24 | var keys = Object.keys(files) 25 | .sort(function (a, b) { 26 | if (files[a].entry) return -1; 27 | if (files[b].entry) return 1; 28 | if (files[a].size > files[b].size) return -1; 29 | if (files[a].size < files[b].size) return 1; 30 | return 0; 31 | }) 32 | ; 33 | keys.forEach(function (k) { 34 | console.log(chalk.bold.red(k + (files[k].entry ? ' [entry]' : ''))); 35 | console.log(chalk.green(' chars: ') + files[k].size); 36 | console.log(chalk.green(' included by:')); 37 | for (var inc in incBy[k]) console.log(' ' + inc); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 26 | 27 | 28 |

H1 heading

29 | 30 | 34 | 35 | 38 | 39 |

40 | One sentence only. 41 |

42 | 43 |

44 | Dr. N.I.H police. 45 |

46 | 47 |

48 | One non terminated sentence 49 |

50 | 51 |

52 | 53 | a 54 | b 55 | 56 | c 57 |

58 | 59 |

60 | Lorem ipsum dolor sit amet, 61 | consectetur adipiscing 62 | elit. Curabitur porta nulla nec 63 | volutpat iaculis. Vivamus sed tellus sed tortor ultrices 64 | posuere. Praesent porta, felis at congue tincidunt, eros dui 65 | hendrerit massa, vitae tincidunt enim diam a diam. Vivamus 66 | ultrices nibh dolor, et accumsan nisl tincidunt et. Mauris 67 | ornare tellus quis enim gravida, id tempor augue porta. Nunc 68 | ultrices felis rhoncus vulputate egestas. Etiam nisi magna, 69 | bibendum ut facilisis ornare, suscipit a risus. Maecenas 70 | semper lacinia arcu, eu convallis turpis. Etiam pharetra 71 | elementum gravida. In vitae aliquet odio, non ultricies 72 | risus. Quisque ut urna sit amet velit pharetra vestibulum et 73 | sed tellus. 74 |

75 | 76 |

77 | Duis bibendum nulla dictum tempor euismod. Fusce sed dui in 78 | magna gravida fermentum in sed felis. Nulla eu leo molestie, 79 | luctus massa consequat, aliquet lorem. Vivamus nec euismod 80 | 81 | nulla. Maecenas pulvinar semper pharetra. Aenean vitae dolor 82 | ornare, congue nunc eu, lobortis arcu. In ac magna lacinia, 83 | ultrices quam ultricies, volutpat augue. 84 |

85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /karma.conf-ci.js: -------------------------------------------------------------------------------- 1 | 2 | var fs = require('fs') 3 | , localConf = require('./karma.conf') 4 | ; 5 | 6 | // karma for CI 7 | module.exports = function (config) { 8 | // Use ENV vars on Travis and sauce.json locally to get credentials 9 | if (!process.env.SAUCE_USERNAME) { 10 | if (!fs.existsSync('sauce.json')) { 11 | console.log('Create a sauce.json with your credentials based on the sauce-sample.json file.'); 12 | process.exit(1); 13 | } 14 | else { 15 | process.env.SAUCE_USERNAME = require('./sauce.json').username; 16 | process.env.SAUCE_ACCESS_KEY = require('./sauce.json').accessKey; 17 | } 18 | } 19 | // Browsers to run on Sauce Labs 20 | var customLaunchers = { 21 | // chrome, two versions back 22 | 'sl_chrome-latest': { 23 | base: 'SauceLabs', 24 | browserName: 'chrome', 25 | version: '45' 26 | }, 27 | 'sl_chrome-1': { 28 | base: 'SauceLabs', 29 | browserName: 'chrome', 30 | version: '44' 31 | }, 32 | 'sl_chrome-2': { 33 | base: 'SauceLabs', 34 | browserName: 'chrome', 35 | version: '43' 36 | }, 37 | 38 | // IE, back to IE9 39 | 'sl_ie-latest': { 40 | base: 'SauceLabs', 41 | browserName: 'microsoftedge', 42 | }, 43 | 'sl_ie-11': { 44 | base: 'SauceLabs', 45 | browserName: 'internet explorer', 46 | version: '11' 47 | }, 48 | 'sl_ie-10': { 49 | base: 'SauceLabs', 50 | browserName: 'internet explorer', 51 | version: '10' 52 | }, 53 | 'sl_ie-9': { 54 | base: 'SauceLabs', 55 | browserName: 'internet explorer', 56 | version: '9' 57 | }, 58 | 59 | // Firefox, two versions back 60 | 'sl_firefox-latest': { 61 | base: 'SauceLabs', 62 | browserName: 'firefox', 63 | version: '41' 64 | }, 65 | 'sl_firefox-1': { 66 | base: 'SauceLabs', 67 | browserName: 'firefox', 68 | version: '40' 69 | }, 70 | 'sl_firefox-2': { 71 | base: 'SauceLabs', 72 | browserName: 'firefox', 73 | version: '39' 74 | }, 75 | 76 | // Safari, desktop, two versions back 77 | 'sl_safari-latest': { 78 | base: 'SauceLabs', 79 | browserName: 'safari', 80 | version: '8' 81 | }, 82 | 'sl_safari-1': { 83 | base: 'SauceLabs', 84 | browserName: 'safari', 85 | version: '7' 86 | }, 87 | 'sl_safari-2': { 88 | base: 'SauceLabs', 89 | browserName: 'safari', 90 | version: '6' 91 | }, 92 | 93 | // iOS, two versions back 94 | 'sl_ios-latest': { 95 | base: 'SauceLabs', 96 | browserName: 'iphone', 97 | version: '8' 98 | }, 99 | 'sl_ios-1': { 100 | base: 'SauceLabs', 101 | browserName: 'iphone', 102 | version: '7' 103 | }, 104 | 'sl_ios-2': { 105 | base: 'SauceLabs', 106 | browserName: 'iphone', 107 | version: '6' 108 | }, 109 | 110 | // Android, down to 4.3 111 | 'sl_android-latest': { 112 | base: 'SauceLabs', 113 | browserName: 'android', 114 | version: '5.1' 115 | }, 116 | 'sl_android-1': { 117 | base: 'SauceLabs', 118 | browserName: 'android', 119 | version: '4.3' 120 | }, 121 | 'sl_android-2': { 122 | base: 'SauceLabs', 123 | browserName: 'android', 124 | version: '4.2' 125 | } 126 | }; 127 | 128 | localConf(config); 129 | config.set({ 130 | sauceLabs: { 131 | testName: 'Web Verse' 132 | }, 133 | captureTimeout: 120000, 134 | customLaunchers: customLaunchers, 135 | reporters: ['dots', 'saucelabs', 'coverage', 'coveralls'], 136 | preprocessors: { 137 | 'web-verse.js': ['coverage'] 138 | }, 139 | coverageReporter: { 140 | type: 'lcov', 141 | dir: 'coverage/' 142 | }, 143 | 144 | // start these browsers 145 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 146 | browsers: Object.keys(customLaunchers), 147 | singleRun: true 148 | }); 149 | }; 150 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | // base path that will be used to resolve all patterns (eg. files, exclude) 4 | basePath: '', 5 | // frameworks to use, from: https://npmjs.org/browse/keyword/karma-adapter 6 | frameworks: ['mocha', 'chai'], 7 | // list of files / patterns to load in the browser 8 | files: ['web-verse.min.js', 'test/*.js'], 9 | // test results reporter to use, from https://npmjs.org/browse/keyword/karma-reporter 10 | // possible values: 'dots', 'progress' 11 | reporters: ['progress'], 12 | // web server port 13 | port: 9876, 14 | colors: true, 15 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 16 | logLevel: config.LOG_INFO, 17 | // enable / disable watching file and executing tests whenever any file changes 18 | autoWatch: true, 19 | autoWatchBatchDelay: 1000, 20 | // Start these browsers, currently available: 21 | // - Chrome 22 | // - ChromeCanary 23 | // - Firefox 24 | // - Opera 25 | // - Safari (only Mac) 26 | // - PhantomJS 27 | // - IE (only Windows) 28 | browsers: ['Chrome'], 29 | // Continuous Integration mode 30 | // if true, Karma captures browsers, runs the tests and exits 31 | singleRun: false 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-verse", 3 | "version": "2.1.2", 4 | "description": "Toolbox for deep, resilient, markup-invariant linking into HTML documents without their coöperation", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build-node": "rm -rf dist && babel src --out-dir dist", 8 | "build-cover": "browserify src/index.js -o web-verse.js", 9 | "build-browser": "browserify src/index.js | uglifyjs - -c warnings=false -m > web-verse.min.js", 10 | "show-build": "browserify --deps src/index.js", 11 | "build": "npm run build-node && npm run build-browser && npm run build-cover", 12 | "watch-node": "nodemon -e js -w src/ --exec 'npm run build-node'", 13 | "watch-cover": "watchify src/index.js -o web-verse.js", 14 | "watch-browser": "watchify src/index.js -o 'uglifyjs - -c warnings=false -m > web-verse.min.js'", 15 | "watch": "concurrently --kill-others --prefix command 'npm run watch-node' 'npm run watch-browser' 'npm run watch-cover'", 16 | "test": "karma start karma.conf-ci.js", 17 | "test-local": "karma start karma.conf.js", 18 | "pretest": "npm run build", 19 | "prepublish": "npm run build" 20 | }, 21 | "babel": { 22 | "presets": [ 23 | [ 24 | "env", 25 | { 26 | "targets": "> 0.25%, not dead" 27 | } 28 | ] 29 | ] 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/science-periodicals/web-verse.git" 34 | }, 35 | "keywords": [ 36 | "deep", 37 | "linking", 38 | "hyperlink" 39 | ], 40 | "contributors": [ 41 | "Sebastien Ballesteros", 42 | "Robin Berjon" 43 | ], 44 | "license": "Apache-2.0", 45 | "bugs": { 46 | "url": "https://github.com/science-periodicals/web-verse/issues" 47 | }, 48 | "homepage": "https://github.com/science-periodicals/web-verse", 49 | "dependencies": { 50 | "escape-regex-string": "^1.0.4", 51 | "fast-levenshtein": "^2.0.6", 52 | "sbd": "^1.0.12", 53 | "spark-md5": "^3.0.0" 54 | }, 55 | "devDependencies": { 56 | "@scipe/eslint-config": "^1.0.0", 57 | "babel-cli": "^6.26.0", 58 | "babel-core": "^6.26.0", 59 | "babel-preset-env": "^1.6.1", 60 | "babelify": "^8.0.0", 61 | "browserify": "^14.5.0", 62 | "chai": "^3.5.0", 63 | "chalk": "^1.1.3", 64 | "concurrently": "^3.5.0", 65 | "jsdom": "^9.11.0", 66 | "karma": "^1.7.1", 67 | "karma-chai": "^0.1.0", 68 | "karma-chrome-launcher": "^2.2.0", 69 | "karma-coverage": "^1.1.1", 70 | "karma-coveralls": "^1.1.2", 71 | "karma-firefox-launcher": "^1.0.1", 72 | "karma-mocha": "^1.3.0", 73 | "karma-safari-launcher": "^1.0.0", 74 | "karma-sauce-launcher": "^1.2.0", 75 | "mocha": "^3.2.0", 76 | "nodemon": "^1.12.1", 77 | "uglify-js": "^2.7.5", 78 | "watchify": "^3.9.0" 79 | }, 80 | "browserify": { 81 | "transform": [ 82 | [ 83 | "babelify", 84 | { 85 | "presets": [ 86 | "env" 87 | ] 88 | } 89 | ] 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // inspired by https://github.com/NYTimes/Emphasis 2 | 3 | import SparkMD5 from 'spark-md5'; 4 | import sbd from 'sbd'; 5 | import levenshtein from 'fast-levenshtein'; 6 | import escapeRegex from 'escape-regex-string'; 7 | 8 | const TEXT_NODE = 3; 9 | const SHOW_TEXT = 4; 10 | // welcome to a world of horrors 11 | // this is here because I don't trust \s to be correct across browsers 12 | // TODO: if we can ascertain that it is reliable enough, or make Babel transforms it correctly with /u 13 | // inline this whole mess 14 | const SPACE = 15 | '[ \\f\\n\\r\\t\\v​\u00a0\\u1680​\\u180e\\u2000-\\u200a​\\u2028\\u2029\\u202f\\u205f​\\u3000\\ufeff]'; 16 | const NOT_SPACE = SPACE.replace('[', '[^'); 17 | const RE_ONLY_SPACE = new RegExp('^' + SPACE + '+$'); 18 | const RE_SPACES = new RegExp(SPACE + '+'); 19 | const RE_SPACES_CAPTURE = new RegExp('(' + SPACE + '+)'); 20 | const RE_SPACES_GLOBAL = new RegExp(SPACE + '+', 'g'); 21 | const RE_SPACE_GLOBAL = new RegExp(SPACE, 'g'); 22 | const RE_NOT_SPACES = new RegExp(NOT_SPACE + '+'); 23 | const RE_TRIM_LEFT = new RegExp('^' + SPACE + '*'); 24 | const RE_TRIM_RIGHT = new RegExp(SPACE + '*$'); 25 | const RE_TRIM_LEFT_CAPTURE = new RegExp('^(' + SPACE + '*)'); 26 | const RE_TRIM_RIGHT_CAPTURE = new RegExp('(' + SPACE + '*)$'); 27 | const RE_TRIM = new RegExp('^' + SPACE + '*|' + SPACE + '*$', 'g'); 28 | const leftTrim = str => String(str).replace(RE_TRIM_LEFT, ''); 29 | const rightTrim = str => String(str).replace(RE_TRIM_RIGHT, ''); 30 | const trim = str => String(str || '').replace(RE_TRIM, ''); 31 | export let citeable = [ 32 | 'P', 33 | 'LI', 34 | 'DD', 35 | 'DT', 36 | 'BLOCKQUOTE', 37 | 'H1', 38 | 'H2', 39 | 'H3', 40 | 'H4', 41 | 'H5', 42 | 'H6', 43 | 'FIGCAPTION', 44 | 'CAPTION', 45 | 'ASIDE', 46 | 'SECTION', 47 | 'ARTICLE', 48 | 'BODY', 49 | 'DIV', 50 | 'MAIN', 51 | 'MATH' 52 | ]; 53 | 54 | /** 55 | * From a block element, generate a Key 56 | * - Break block element text content into Sentences 57 | * - Take first and last Sentences 58 | * - Sometimes the same. That's ok. 59 | * - First character from the first three words of each sentence 60 | * - Each 6 char key refers to specific block element 61 | */ 62 | export function createKey($el) { 63 | let key = ''; 64 | let len = 6; 65 | let txt = normalizeText($el.textContent || '').replace(/[^\w\. ]+/giu, ''); 66 | 67 | if (txt && txt.length > 1) { 68 | let lines = sbd 69 | .sentences(txt) 70 | .map(x => trim(x)) 71 | .filter(x => x); 72 | if (lines.length) { 73 | let first = lines[0].match(/\S+/gu).slice(0, len / 2); 74 | let last = lines[lines.length - 1].match(/\S+/gu).slice(0, len / 2); 75 | let k = first.concat(last); 76 | 77 | let max = k.length > len ? len : k.length; 78 | 79 | for (var i = 0; i < max; i++) { 80 | key += k[i].substring(0, 1); 81 | } 82 | } 83 | } 84 | 85 | return key; 86 | } 87 | 88 | // create a md5 hash for the trimmed content of the given element 89 | export function createHash($el) { 90 | return SparkMD5.hash(normalizeText($el.textContent || $el || '')); // `|| $el` so that `$el` can be a string 91 | } 92 | 93 | // given a node or range, find the enclosing block element that is part of our whitelist 94 | export function getScope(nodeOrRange) { 95 | var $scope = nodeOrRange.nodeType 96 | ? nodeOrRange 97 | : nodeOrRange.commonAncestorContainer; 98 | // get closest Element 99 | if ($scope.nodeType === TEXT_NODE) $scope = $scope.parentNode; 100 | 101 | // get closest citeable element 102 | while (!~citeable.indexOf($scope.tagName)) { 103 | $scope = $scope.parentNode; 104 | if ($scope.tagName === 'HTML') return; 105 | } 106 | return $scope; 107 | } 108 | 109 | // given a range and a scope, returns a data structure with all the details needed to reconstruct it 110 | export function serializeRange(range, $scope = getScope(range)) { 111 | if (!$scope) return; 112 | let offsets = getOffsets(range, $scope); 113 | return { 114 | $scope: $scope, 115 | hash: createHash($scope), 116 | key: createKey($scope), 117 | startOffset: offsets.startOffset, 118 | endOffset: offsets.endOffset 119 | }; 120 | } 121 | 122 | // serialise the selection as a range 123 | export function serializeSelection() { 124 | let selection = window.getSelection(); 125 | if (!selection.isCollapsed) return serializeRange(selection.getRangeAt(0)); 126 | } 127 | 128 | // The same as `serializeRange()` but instead of a `Range` it uses a node, taking its own text 129 | // content as the offsets into the given scope. If no scoping `$el` is given, it will use 130 | // `getScope($node)`. 131 | export function serializeNode($node, $scope = getScope($node)) { 132 | if (!$scope) return; 133 | let offsets = getChildOffsets($scope, $node); 134 | return { 135 | $scope: $scope, 136 | hash: createHash($scope), 137 | key: createKey($scope), 138 | startOffset: offsets.startOffset, 139 | endOffset: offsets.endOffset 140 | }; 141 | } 142 | 143 | // given a scope and normalised start/end offsets that ignore white space, returns a range using the 144 | // raw offsets 145 | export function rangeFromOffsets($scope, startOffset, endOffset) { 146 | let node, 147 | it = $scope.ownerDocument.createNodeIterator($scope, SHOW_TEXT, null, true), 148 | accumulator = 0, 149 | startNode, 150 | endNode, 151 | relStartOffset, 152 | relEndOffset; 153 | 154 | // get the raw offsets, then simply operate only taking that into account 155 | startOffset = denormalizeOffset(startOffset, $scope.textContent); 156 | endOffset = denormalizeOffset(endOffset, $scope.textContent); 157 | while ((node = it.nextNode())) { 158 | let tc = node.textContent; 159 | if ( 160 | relStartOffset === undefined && 161 | accumulator + tc.length >= startOffset 162 | ) { 163 | startNode = node; 164 | relStartOffset = startOffset - accumulator; 165 | } 166 | if (relEndOffset === undefined && accumulator + tc.length >= endOffset) { 167 | endNode = node; 168 | relEndOffset = endOffset - accumulator; 169 | break; 170 | } 171 | accumulator += tc.length; 172 | } 173 | 174 | var range = $scope.ownerDocument.createRange(); 175 | range.setStart(startNode, relStartOffset); 176 | range.setEnd(endNode, relEndOffset); 177 | return range; 178 | } 179 | 180 | // given a target key and a list of candidates, find either an exact match, or one with the smallest 181 | // Levenshtein edit distance. If no candidate has an edit distance less than three, the match is 182 | // undefined. 183 | export function findKey(target, candidates) { 184 | let x = { index: undefined, value: undefined, lev: undefined }; 185 | 186 | for (let i = 0; i < candidates.length; i++) { 187 | if (target === candidates[i]) { 188 | return { index: i, value: candidates[i], lev: 0 }; 189 | } else { 190 | // look for 1st closest Match 191 | let ls = levenshtein.get(target.slice(0, 3), candidates[i].slice(0, 3)); 192 | let le = levenshtein.get(target.slice(-3), candidates[i].slice(-3)); 193 | let lev = ls + le; 194 | if (lev < 3 && (x.lev === undefined || lev < x.lev)) { 195 | x.index = i; 196 | x.value = candidates[i]; 197 | x.lev = lev; 198 | } 199 | } 200 | } 201 | 202 | return x; 203 | } 204 | 205 | function textNodeFromNode($container) { 206 | if ($container.nodeType === TEXT_NODE) return $container; 207 | let $node; 208 | let it = $container.ownerDocument.createNodeIterator( 209 | $container, 210 | SHOW_TEXT, 211 | null, 212 | true 213 | ); 214 | while (($node = it.nextNode())) { 215 | if ($node.nodeType === TEXT_NODE) return $node; 216 | } 217 | } 218 | 219 | function lastTextNode($container) { 220 | if ($container.nodeType === TEXT_NODE) return $container; 221 | let $node, $lastTextNode; 222 | let it = $container.ownerDocument.createNodeIterator( 223 | $container, 224 | SHOW_TEXT, 225 | null, 226 | true 227 | ); 228 | while (($node = it.nextNode())) { 229 | if ($node.nodeType === TEXT_NODE) $lastTextNode = $node; 230 | } 231 | 232 | return $lastTextNode; 233 | } 234 | 235 | // Given a range and an element scope, return the start and end offsets into the text that ignore 236 | // white space. 237 | export function getOffsets(range, $scope) { 238 | let startTextNode = textNodeFromNode(range.startContainer), 239 | endTextNode = lastTextNode(range.endContainer); 240 | 241 | let node, 242 | textNodes = [], 243 | rawStartOffset, 244 | rawEndOffset, 245 | it = $scope.ownerDocument.createNodeIterator($scope, SHOW_TEXT, null, true); 246 | while ((node = it.nextNode())) { 247 | if (node === startTextNode) { 248 | rawStartOffset = textNodes 249 | .map(tn => tn.textContent.length) 250 | .reduce((a, b) => { 251 | return a + b; 252 | }, range.startOffset); 253 | } 254 | if (node === endTextNode) { 255 | rawEndOffset = textNodes 256 | .map(tn => tn.textContent.length) 257 | .reduce((a, b) => { 258 | return a + b; 259 | }, range.endOffset); 260 | } 261 | textNodes.push(node); 262 | if (rawEndOffset !== undefined) break; 263 | } 264 | var txt = textNodes.map(t => t.textContent).join(''); 265 | return { 266 | startOffset: normalizeOffset(rawStartOffset, txt), 267 | endOffset: normalizeOffset(rawEndOffset, txt) 268 | }; 269 | } 270 | 271 | // get the normalised offsets of the start and end of a given child text node (or element containing 272 | // one) 273 | export function getChildOffsets($parent, $child) { 274 | let startTextNode = textNodeFromNode($child), 275 | endTextNode = lastTextNode($child); 276 | 277 | let node, 278 | rawStartOffset, 279 | rawEndOffset, 280 | textNodes = [], 281 | it = $parent.ownerDocument.createNodeIterator( 282 | $parent, 283 | SHOW_TEXT, 284 | null, 285 | true 286 | ); 287 | while ((node = it.nextNode())) { 288 | if (node === startTextNode) { 289 | rawStartOffset = textNodes 290 | .map(tn => tn.textContent.length) 291 | .reduce((a, b) => { 292 | return a + b; 293 | }, 0); 294 | } 295 | if (node === endTextNode) { 296 | rawEndOffset = textNodes 297 | .map(tn => tn.textContent.length) 298 | .reduce((a, b) => { 299 | return a + b; 300 | }, node.textContent.length); 301 | } 302 | textNodes.push(node); 303 | if (rawEndOffset !== undefined) break; 304 | } 305 | 306 | return { 307 | startOffset: normalizeOffset(rawStartOffset, $parent.textContent), 308 | endOffset: normalizeOffset(rawEndOffset, $parent.textContent) 309 | }; 310 | } 311 | 312 | // given a scope and a string, it will find all instances of that string within the 313 | // scope, and use that to create white-space-independent ranges 314 | export function getRangesFromText($scope, text) { 315 | // make the text safe to search, but spaces in it need to match \s+ 316 | text = escapeRegex(trim(text)).replace(RE_SPACES_GLOBAL, SPACE + '+'); 317 | let re = new RegExp(text, 'gi'), 318 | tc = $scope.textContent; 319 | // We need to get an offset that ignores whitespace, BUT the regex needs to match 320 | // if it contains whitespace. 321 | // So we: 322 | // get all match indices on raw text 323 | // map start and end indices to normalised text 324 | let result, 325 | matchIndexes = []; 326 | while ((result = re.exec(tc)) !== null) { 327 | matchIndexes.push({ index: result.index, length: result[0].length }); 328 | } 329 | 330 | return matchIndexes.map(function(match) { 331 | return rangeFromOffsets( 332 | $scope, 333 | normalizeOffset(match.index, tc), 334 | normalizeOffset(match.index + match.length, tc) 335 | ); 336 | }); 337 | } 338 | 339 | // returns text that has been trimmed and with all white space normalised to space 340 | export function normalizeText(text) { 341 | return trim(text || '').replace(RE_SPACES_GLOBAL, ' '); 342 | } 343 | 344 | // Takes a raw offset into a raw text and returns the offset of the same character in a normalised 345 | // text. 346 | export function normalizeOffset(rawOffset, rawText) { 347 | let workText = rawText.substring(0, rawOffset); 348 | // the length difference once left-trim and space normalisation have happened 349 | let delta = 350 | workText.length - 351 | workText.replace(RE_TRIM_LEFT, '').replace(RE_SPACES_GLOBAL, ' ').length; 352 | return rawOffset - delta; 353 | } 354 | 355 | // Takes a normalised offset and a raw text, and returns the corresponding raw offset 356 | export function denormalizeOffset(normOffset, rawText) { 357 | normOffset = parseInt(normOffset, 10); 358 | // process the text into blocks that are either: 359 | // - simple non-white-space text 360 | // - simple white space 361 | // in each case the block is given a normalised length, which is how long it would be in normal 362 | // text, and a raw length, which is its actual length. The first and last white space blocks gets 363 | // normal lengths of 0, others of 1. 364 | // then for each block, if the normal offset would be in the block we compute it, otherwise we 365 | // increment our raw and normal offsets and move to the next block 366 | let leftTrimMatch = rawText.match(RE_TRIM_LEFT_CAPTURE), 367 | rightTrimMatch = rawText.match(RE_TRIM_RIGHT_CAPTURE); 368 | rawText = trim(rawText); 369 | let blocks = rawText.split(RE_SPACES_CAPTURE).map(b => { 370 | return { 371 | rawLength: b.length, 372 | normalLength: RE_ONLY_SPACE.test(b) ? 1 : b.length 373 | }; 374 | }); 375 | blocks.unshift({ rawLength: leftTrimMatch[1].length, normalLength: 0 }); 376 | blocks.push({ rawLength: rightTrimMatch[1].length, normalLength: 0 }); 377 | 378 | let rawOffset = 0, 379 | normalisingOffset = 0; 380 | for (var i = 0; i < blocks.length; i++) { 381 | let block = blocks[i]; 382 | if (block.normalLength + normalisingOffset >= normOffset) { 383 | return ( 384 | rawOffset + 385 | (block.rawLength - block.normalLength) + 386 | (normOffset - normalisingOffset) 387 | ); 388 | } else { 389 | rawOffset += block.rawLength; 390 | normalisingOffset += block.normalLength; 391 | } 392 | } 393 | return rawOffset; 394 | } 395 | 396 | if (typeof window === 'object') window.WebVerse = exports; 397 | -------------------------------------------------------------------------------- /test/_utils.js: -------------------------------------------------------------------------------- 1 | 2 | var hashedHello = '8b1a9953c4611296a827abf8c47804d7' 3 | , hashedWorld = '7d793037a0760186574b0282f2f435e7' 4 | , hashedHW = '5eb63bbbe01eeed093cb22bb8f5acdc3' 5 | ; 6 | function htmlContent (html) { 7 | var mains = document.getElementsByTagName('main'); 8 | while (mains.length) document.body.removeChild(mains.item(0)); 9 | var main = document.createElement('main'); 10 | main.innerHTML = html; 11 | document.body.appendChild(main); 12 | } 13 | -------------------------------------------------------------------------------- /test/child-offsets.js: -------------------------------------------------------------------------------- 1 | 2 | (function () { 3 | var cases = [ 4 | { 5 | desc: 'should work on the simplest child', 6 | html: '

a

', 7 | parent: 'p', 8 | child: 'span', 9 | offsets: [0, 1] 10 | }, 11 | { 12 | desc: 'should work with prefix text', 13 | html: '

aha abc

', 14 | parent: 'p', 15 | child: 'span', 16 | offsets: [4, 7] 17 | }, 18 | { 19 | desc: 'should work on second child', 20 | html: '

aha abc hmm x

', 21 | parent: 'p', 22 | child: 'strong', 23 | offsets: [12, 14] 24 | }, 25 | { 26 | desc: 'should work when first text node is not a direct child of the child', 27 | html: '

hello

', 28 | parent: 'article', 29 | child: 'section', 30 | offsets: [0, 5] 31 | }, 32 | { 33 | desc: 'should work when the last text node is not the same node as the first text node', 34 | html: '

hello

world

hola

', 35 | parent: 'article', 36 | child: 'section', 37 | offsets: [0, 10] 38 | } 39 | ]; 40 | 41 | describe('WebVerse getChildOffsets', function () { 42 | cases.forEach(function (c) { 43 | it(c.desc, function () { 44 | htmlContent(c.html); 45 | var $parent = document.getElementsByTagName(c.parent)[0] 46 | , $child = document.getElementsByTagName(c.child)[0] 47 | , offsets = WebVerse.getChildOffsets($parent, $child) 48 | ; 49 | assert.equal(offsets.startOffset, c.offsets[0], 'start offsets match'); 50 | assert.equal(offsets.endOffset, c.offsets[1], 'end offsets match'); 51 | }); 52 | }); 53 | }); 54 | })(); 55 | -------------------------------------------------------------------------------- /test/create-hash.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var cases = [ 3 | { 4 | desc: 'should without space', 5 | in: 'Hello', 6 | out: hashedHello 7 | }, 8 | { 9 | desc: 'should hash with trimmable space', 10 | in: ' \f\vworld\n\t\u00a0', 11 | out: hashedWorld 12 | }, 13 | { 14 | desc: 'should hash with normalised space', 15 | in: '\uFEFF\n \thello\r \nworld\xA0\t\r', 16 | out: hashedHW 17 | } 18 | ]; 19 | 20 | describe('WebVerse createHash', function() { 21 | cases.forEach(function(c) { 22 | it(c.desc, function() { 23 | var $el = document.createElement('div'); 24 | $el.textContent = c.in; 25 | var out = WebVerse.createHash($el); 26 | assert.equal(out, c.out, 'hashes'); 27 | }); 28 | }); 29 | }); 30 | })(); 31 | -------------------------------------------------------------------------------- /test/create-key.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | var cases = [ 3 | { 4 | desc: 'should compute a key over 2 simple sentences', 5 | text: ' I am a paragraph with 2 sentences. I am the second sentence.', 6 | key: 'IaaIat' 7 | }, 8 | { 9 | desc: 'should use the one sentence twice', 10 | text: ' I am a paragraph with 2 sentences.\n', 11 | key: 'IaaIaa' 12 | }, 13 | { 14 | desc: 'should work with too few words', 15 | text: ' hello world\n', 16 | key: 'hwhw' 17 | }, 18 | { 19 | desc: 'should work with too few words', 20 | text: ' Hello! World!\n', 21 | key: 'HWHW' 22 | }, 23 | { 24 | desc: 'should handle only spaces', 25 | text: ' \n', 26 | key: '' 27 | }, 28 | { 29 | desc: 'should handle the void', 30 | text: '', 31 | key: '' 32 | }, 33 | ]; 34 | describe('WebVerse createKey', function () { 35 | cases.forEach(function (c) { 36 | it(c.desc, function () { 37 | htmlContent('

' + c.text + '

'); 38 | var $p = document.getElementsByTagName('p')[0] 39 | , key = WebVerse.createKey($p) 40 | ; 41 | assert.equal(key, c.key, 'created key matches'); 42 | }); 43 | }); 44 | }); 45 | })(); 46 | -------------------------------------------------------------------------------- /test/find-key.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | var cases = [ 3 | { 4 | desc: 'should find a direct match', 5 | target: 'abcdef', 6 | candidates: ['qwerty', 'asdfgh', 'abcdef'], 7 | resIndex: 2, 8 | resValue: 'abcdef', 9 | resLev: 0 10 | }, 11 | { 12 | desc: 'should find no match', 13 | target: 'abcdef', 14 | candidates: ['qwerty', 'asdfgh'], 15 | resIndex: undefined, 16 | resValue: undefined, 17 | resLev: undefined 18 | }, 19 | { 20 | desc: 'should find no match: target=3, cands=6', 21 | target: 'abc', 22 | candidates: ['qwerty', 'asdfgh', 'abcdef'], 23 | resIndex: undefined, 24 | resValue: undefined, 25 | resLev: undefined 26 | }, 27 | { 28 | desc: 'should find with: target=6, cands=3+one good', 29 | target: 'abcdef', 30 | candidates: ['qwe', 'asd', 'abcdef'], 31 | resIndex: 2, 32 | resValue: 'abcdef', 33 | resLev: 0 34 | }, 35 | { 36 | desc: 'should find no match: target=6, cands=3', 37 | target: 'abcdef', 38 | candidates: ['qwe', 'asd', 'abc'], 39 | resIndex: undefined, 40 | resValue: undefined, 41 | resLev: undefined 42 | }, 43 | { 44 | desc: 'should find match: target=2, cands=2', 45 | target: 'ab', 46 | candidates: ['ab', 'qwe', 'asd'], 47 | resIndex: 0, 48 | resValue: 'ab', 49 | resLev: 0 50 | }, 51 | { 52 | desc: 'should find match: target=1, cands=1', 53 | target: 'a', 54 | candidates: ['ab', 'qwe', 'a'], 55 | resIndex: 2, 56 | resValue: 'a', 57 | resLev: 0 58 | }, 59 | { 60 | desc: 'should find match: target=0, cands=0', 61 | target: '', 62 | candidates: ['ab', '', 'a'], 63 | resIndex: 1, 64 | resValue: '', 65 | resLev: 0 66 | }, 67 | { 68 | desc: 'should find with lev=1, sizes=6', 69 | target: 'abcdef', 70 | candidates: ['abcdeg', '', 'a'], 71 | resIndex: 0, 72 | resValue: 'abcdeg', 73 | resLev: 1 74 | }, 75 | { 76 | desc: 'should find with lev=2, sizes=6', 77 | target: 'abcdef', 78 | candidates: ['axxdeg', 'abcdfg', 'a'], 79 | resIndex: 1, 80 | resValue: 'abcdfg', 81 | resLev: 2 82 | }, 83 | { 84 | desc: 'should find with lev=2, sizes=5/6', 85 | target: 'abcde', 86 | candidates: ['abcdef', 'abcdfg', 'a'], 87 | resIndex: 0, 88 | resValue: 'abcdef', 89 | resLev: 2 90 | }, 91 | { 92 | desc: 'should find with lev=2, sizes=6/5', 93 | target: 'abcdef', 94 | candidates: ['abcde', 'abcdfg', 'a'], 95 | resIndex: 0, 96 | resValue: 'abcde', 97 | resLev: 2 98 | }, 99 | { 100 | desc: 'should find with lev=2, permutation', 101 | target: 'abcdef', 102 | candidates: ['abcdfe', 'abcdfg', 'a'], 103 | resIndex: 0, 104 | resValue: 'abcdfe', 105 | resLev: 2 106 | }, 107 | ]; 108 | describe('WebVerse findKey', function () { 109 | cases.forEach(function (c) { 110 | it(c.desc, function () { 111 | var res = WebVerse.findKey(c.target, c.candidates); 112 | assert.equal(res.index, c.resIndex, 'indices match'); 113 | assert.equal(res.value, c.resValue, 'values match'); 114 | assert.equal(res.lev, c.resLev, 'lev distances match'); 115 | }); 116 | }); 117 | }); 118 | })(); 119 | -------------------------------------------------------------------------------- /test/get-scope.js: -------------------------------------------------------------------------------- 1 | 2 | (function () { 3 | function el (ln) { 4 | return document.getElementsByTagName(ln)[0]; 5 | } 6 | 7 | var cases = [ 8 | { 9 | desc: 'should bubble up from the text node', 10 | html: '

a

', 11 | node: function () { return el('p').firstChild; }, 12 | offsets: [0, 1], 13 | check: function ($node) { return $node.id; }, 14 | result: 'x' 15 | }, 16 | { 17 | desc: 'should bubble up from the text node, deeply', 18 | html: '

ab

', 19 | node: function () { return el('span').firstChild; }, 20 | offsets: [0, 1], 21 | check: function ($node) { return $node.id; }, 22 | result: 'x' 23 | }, 24 | { 25 | desc: 'should bubble up from the text node, deeper', 26 | html: '

abbherebb

', 27 | node: function () { return el('strong').firstChild; }, 28 | offsets: [0, 1], 29 | check: function ($node) { return $node.id; }, 30 | result: 'x' 31 | }, 32 | { 33 | desc: 'should stick to the given element', 34 | html: '

ab

', 35 | node: function () { return el('p'); }, 36 | offsets: [0, 1], 37 | check: function ($node) { return $node.id; }, 38 | result: 'x' 39 | }, 40 | { 41 | desc: 'should abort at root ', 42 | html: 'hello', 43 | node: function () { return el('title'); }, 44 | offsets: [0, 0], 45 | check: function ($node) { return typeof $node; }, 46 | result: 'undefined' 47 | }, 48 | ]; 49 | 50 | describe('WebVerse getScope', function () { 51 | cases.forEach(function (c) { 52 | it(c.desc, function () { 53 | htmlContent(c.html); 54 | var $node = c.node() 55 | , range = document.createRange() 56 | ; 57 | range.setStart($node, c.offsets[0]); 58 | range.setEnd($node, c.offsets[1]); 59 | var $scope = WebVerse.getScope(range); 60 | assert.equal(c.check($scope), c.result, 'check on range produces ' + c.result); 61 | $scope = WebVerse.getScope($node); 62 | assert.equal(c.check($scope), c.result, 'check on node produces ' + c.result); 63 | }); 64 | }); 65 | }); 66 | })(); 67 | -------------------------------------------------------------------------------- /test/normalize-offset.js: -------------------------------------------------------------------------------- 1 | 2 | (function () { 3 | var cases = [ 4 | { 5 | desc: 'should handle a trivial string', 6 | text: 'a', 7 | raw: 0, 8 | norm: 0 9 | }, 10 | { 11 | desc: 'should handle a trivial string, inside', 12 | text: 'a', 13 | raw: 1, 14 | norm: 1 15 | }, 16 | { 17 | desc: 'should handle a little bit of space', 18 | text: ' a', 19 | raw: 1, 20 | norm: 0 21 | }, 22 | { 23 | desc: 'should handle trimming', 24 | text: ' \n\t\r\uFEFFa', 25 | raw: 8, 26 | norm: 0 27 | }, 28 | { 29 | desc: 'should handle normalising', 30 | text: ' \n\t\r\uFEFFa\n\t\r b', 31 | raw: 13, 32 | norm: 2 33 | }, 34 | { 35 | desc: 'should handle end', 36 | text: ' a b ', 37 | raw: 4, 38 | norm: 3 39 | }, 40 | { 41 | desc: 'should handle between', 42 | text: ' a b ', 43 | raw: 4, 44 | norm: 2 45 | }, 46 | { 47 | desc: 'should handle case from ranges', 48 | text: 'a b a b', 49 | raw: 5, 50 | norm: 4 51 | }, 52 | ]; 53 | 54 | describe('WebVerse normalizeOffset', function () { 55 | cases.forEach(function (c) { 56 | it(c.desc + ' [normalising]', function () { 57 | var norm = WebVerse.normalizeOffset(c.raw, c.text); 58 | assert.equal(norm, c.norm, 'offset normalised'); 59 | }); 60 | }); 61 | }); 62 | 63 | describe('WebVerse denormalizeOffset', function () { 64 | cases.forEach(function (c) { 65 | it(c.desc + ' [denormalising]', function () { 66 | var raw = WebVerse.denormalizeOffset(c.norm, c.text); 67 | assert.equal(raw, c.raw, 'offset denormalised'); 68 | }); 69 | }); 70 | }); 71 | })(); 72 | -------------------------------------------------------------------------------- /test/normalize-text.js: -------------------------------------------------------------------------------- 1 | 2 | (function () { 3 | var cases = [ 4 | { 5 | desc: 'should trim pre and post', 6 | in: ' a ', 7 | out: 'a' 8 | }, 9 | { 10 | desc: 'should normalise inside', 11 | in: 'a b', 12 | out: 'a b' 13 | }, 14 | { 15 | desc: 'should normalise repeatedly', 16 | in: 'a b c', 17 | out: 'a b c' 18 | }, 19 | { 20 | desc: 'hairy', 21 | in: '\uFEFF\n \ta\r \nb\xA0\t\r', 22 | out: 'a b' 23 | }, 24 | ]; 25 | 26 | describe('WebVerse normalizeText', function () { 27 | cases.forEach(function (c) { 28 | it(c.desc, function () { 29 | var out = WebVerse.normalizeText(c.in); 30 | assert.equal(out, c.out, 'strings match'); 31 | }); 32 | }); 33 | }); 34 | })(); 35 | -------------------------------------------------------------------------------- /test/offsets.js: -------------------------------------------------------------------------------- 1 | 2 | (function () { 3 | var cases = [ 4 | { 5 | it: 'should handle simple offset into an h1', 6 | html: '

H1 heading

', 7 | start: { el: 'h1', pos: 1 }, 8 | end: { el: 'strong', pos: 4 }, 9 | scope: { el: 'h1' }, 10 | result: { startOffset: 1, endOffset: 7 } 11 | } 12 | , { 13 | it: 'should handle an pretty empty offset (normalise to 0)', 14 | html: ' ', 15 | start: { el: 'ruby', pos: 0 }, 16 | end: { el: 'ruby', pos: 1 }, 17 | scope: { el: 'ruby' }, 18 | result: { startOffset: 0, endOffset: 0 } 19 | } 20 | , { 21 | it: 'should handle offsets nested deeply and disjointly', 22 | html: [ '

', 23 | ' ', 24 | ' a', 25 | ' b', 26 | ' ', 27 | ' c', 28 | '

'].join('\n'), 29 | start: { el: 'span', elNum: 2, pos: 0 }, 30 | end: { el: 'span', elNum: 3, pos: 1 }, 31 | scope: { el: 'p' }, 32 | result: { startOffset: 2, endOffset: 5 } 33 | } 34 | , { 35 | it: 'should handle offsets over empty elements', 36 | html: [ '

', 37 | ' Bibendum ', 38 | '', 39 | 'nulla.', 40 | '

'].join('\n'), 41 | start: { el: 'p', pos: 8 }, 42 | end: { el: 'p', child: 2, pos: 5 }, 43 | scope: { el: 'p' }, 44 | result: { startOffset: 3, endOffset: 13 } 45 | } 46 | ]; 47 | 48 | describe('WebVerse Offsets', function () { 49 | cases.forEach(function (c) { 50 | it(c.it, function () { 51 | htmlContent(c.html); 52 | document.normalize(); 53 | var range = document.createRange() 54 | , startEl = document.getElementsByTagName(c.start.el)[c.start.elNum || 0] 55 | , startNode = c.start.child ? startEl.childNodes.item(c.start.child) : startEl.firstChild 56 | , endEl = document.getElementsByTagName(c.end.el)[c.end.elNum || 0] 57 | , endNode = c.end.child ? endEl.childNodes.item(c.end.child) : endEl.firstChild 58 | ; 59 | range.setStart(startNode, c.start.pos); 60 | range.setEnd(endNode, c.end.pos); 61 | var $scope = document.getElementsByTagName(c.scope.el)[c.scope.elNum || 0]; 62 | var offsets = WebVerse.getOffsets(range, $scope); 63 | assert.equal(offsets.startOffset, c.result.startOffset, 'startOffset is equal'); 64 | assert.equal(offsets.endOffset, c.result.endOffset, 'endOffset is equal'); 65 | }); 66 | }); 67 | }); 68 | })(); 69 | -------------------------------------------------------------------------------- /test/range-from-text.js: -------------------------------------------------------------------------------- 1 | 2 | (function () { 3 | var cases = [ 4 | { 5 | desc: 'should find the simplest string', 6 | html: '

a

', 7 | text: 'a', 8 | ranges: [ 9 | { startID: 'x', start: 0, endID: 'x', end: 1 } 10 | ] 11 | }, 12 | { 13 | desc: 'should handle a bit of space', 14 | html: '

\n a\n\t

', 15 | text: 'a', 16 | ranges: [ 17 | { startID: 'x', start: 4, endID: 'x', end: 5 } 18 | ] 19 | }, 20 | { 21 | desc: 'should handle search string with space', 22 | html: '

a b

', 23 | text: 'a b', 24 | ranges: [ 25 | { startID: 'x', start: 1, endID: 'x', end: 4 } 26 | ] 27 | }, 28 | { 29 | desc: 'should handle search string with space', 30 | html: '

\n a b\na\nb a\t b

', 31 | text: 'a b', 32 | ranges: [ 33 | { startID: 'x', start: 4, endID: 'x', end: 7 }, 34 | { startID: 'x', start: 8, endID: 'x', end: 11 }, 35 | { startID: 'x', start: 13, endID: 'x', end: 19 } 36 | ] 37 | }, 38 | ]; 39 | 40 | describe('WebVerse getRangesFromText', function () { 41 | cases.forEach(function (c) { 42 | it(c.desc, function () { 43 | htmlContent(c.html); 44 | var main = document.getElementsByTagName('main')[0]; 45 | var ranges = WebVerse.getRangesFromText(main, c.text); 46 | assert.equal(ranges.length, c.ranges.length, 'found ' + c.ranges.length + ' instances of ' + c.text); 47 | c.ranges.forEach(function (r, idx) { 48 | assert.equal(ranges[idx].startContainer.parentNode.id, r.startID, 'range ' + idx + ' starts in id=' + r.startID); 49 | assert.equal(ranges[idx].endContainer.parentNode.id, r.endID, 'range ' + idx + ' ends in id=' + r.endID); 50 | assert.equal(ranges[idx].startOffset, r.start, 'range ' + idx + ' starts at ' + r.start); 51 | assert.equal(ranges[idx].endOffset, r.end, 'range ' + idx + ' ends at ' + r.end); 52 | }); 53 | }); 54 | }); 55 | }); 56 | })(); 57 | -------------------------------------------------------------------------------- /test/range-offsets.js: -------------------------------------------------------------------------------- 1 | 2 | (function () { 3 | var cases = [ 4 | { 5 | desc: 'should work on the simplest string', 6 | html: '

a

', 7 | offsets: [0, 1], 8 | range: { startID: 'x', start: 0, endID: 'x', end: 1 } 9 | }, 10 | { 11 | desc: 'should handle a bit of space', 12 | html: '

\n a\n\t

', 13 | offsets: [0, 1], 14 | range: { startID: 'x', start: 4, endID: 'x', end: 5 } 15 | }, 16 | { 17 | desc: 'should handle inner space', 18 | html: '

\n a\n\tb

', 19 | offsets: [0, 3], 20 | range: { startID: 'x', start: 4, endID: 'x', end: 8 } 21 | }, 22 | ]; 23 | 24 | describe('WebVerse rangeFromOffsets', function () { 25 | cases.forEach(function (c) { 26 | it(c.desc, function () { 27 | htmlContent(c.html); 28 | var $main = document.getElementsByTagName('main')[0]; 29 | var range = WebVerse.rangeFromOffsets($main, c.offsets[0], c.offsets[1]) 30 | , r = c.range 31 | ; 32 | assert.equal(range.startContainer.parentNode.id, r.startID, 'range starts in id=' + r.startID); 33 | assert.equal(range.endContainer.parentNode.id, r.endID, 'range ends in id=' + r.endID); 34 | assert.equal(range.startOffset, r.start, 'range starts at ' + r.start); 35 | assert.equal(range.endOffset, r.end, 'range ends at ' + r.end); 36 | }); 37 | }); 38 | }); 39 | 40 | describe('WebVerse getOffsets', function () { 41 | cases.forEach(function (c) { 42 | it(c.desc, function () { 43 | htmlContent(c.html); 44 | var $main = document.getElementsByTagName('main')[0]; 45 | document.normalize; 46 | var range = document.createRange() 47 | , r = c.range; 48 | range.setStart(document.getElementById(r.startID).firstChild, r.start); 49 | range.setEnd(document.getElementById(r.endID).firstChild, r.end); 50 | var offsets = WebVerse.getOffsets(range, $main); 51 | assert.equal(offsets.startOffset, c.offsets[0], 'start offset is ' + c.offsets[0]); 52 | assert.equal(offsets.endOffset, c.offsets[1], 'end offset is ' + c.offsets[1]); 53 | }); 54 | }); 55 | }); 56 | })(); 57 | -------------------------------------------------------------------------------- /test/serialize-range.js: -------------------------------------------------------------------------------- 1 | 2 | (function () { 3 | describe('WebVerse serializeRange', function () { 4 | it('should return nothing if no scope can be found', function () { 5 | var $tit = document.getElementsByTagName('title')[0] 6 | , range = document.createRange() 7 | ; 8 | range.setStart($tit, 0); 9 | range.setEnd($tit, 0); 10 | var res = WebVerse.serializeRange(range); 11 | assert.equal(res, undefined, 'returned undefined for range'); 12 | res = WebVerse.serializeNode($tit); 13 | assert.equal(res, undefined, 'returned undefined for node'); 14 | }); 15 | 16 | it('should default the scope', function () { 17 | htmlContent('

b

'); 18 | var $span = document.getElementsByTagName('span')[0] 19 | , range = document.createRange() 20 | ; 21 | range.setStart($span, 0); 22 | range.setEnd($span, 0); 23 | var res = WebVerse.serializeRange(range); 24 | assert.equal(res.$scope.id, 'x', 'returned scope with id=x for range'); 25 | res = WebVerse.serializeNode($span); 26 | assert.equal(res.$scope.id, 'x', 'returned scope with id=x for node'); 27 | }); 28 | 29 | it('should return proper details', function () { 30 | htmlContent('

\n\thello\nworld \n\f\v

'); 31 | var $p = document.getElementsByTagName('p')[0] 32 | , $main = document.getElementsByTagName('main')[0] 33 | , range = document.createRange() 34 | ; 35 | range.setStart($p.firstChild, 3); 36 | range.setEnd($p.childNodes.item(3).firstChild, 5); 37 | var res = WebVerse.serializeRange(range, $main); 38 | assert.equal(res.$scope, $main, 'used the given scope for range'); 39 | assert.equal(res.hash, hashedHW, 'hashed the content properly for range'); 40 | assert.equal(res.key, 'hwhw', 'generates the correct key for range'); 41 | assert.equal(res.startOffset, 0, 'normalised the start offset for range'); 42 | assert.equal(res.endOffset, 11, 'normalised the end offset for range'); 43 | res = WebVerse.serializeNode($p.childNodes.item(1), $main); 44 | assert.equal(res.$scope, $main, 'used the given scope for node'); 45 | assert.equal(res.hash, hashedHW, 'hashed the content properly for node'); 46 | assert.equal(res.key, 'hwhw', 'generates the correct key for node'); 47 | assert.equal(res.startOffset, 0, 'normalised the start offset for node'); 48 | assert.equal(res.endOffset, 5, 'normalised the end offset for node'); 49 | }); 50 | }); 51 | describe('WebVerse serializeSelection', function () { 52 | it('should ignore collapsed selections', function () { 53 | var res = WebVerse.serializeSelection(); 54 | assert.equal(res, undefined, 'returned undefined'); 55 | }); 56 | it('should serialise a selection', function () { 57 | htmlContent('

\n\thello\nworld \n\f\v

'); 58 | var $p = document.getElementsByTagName('p')[0] 59 | , range = document.createRange() 60 | ; 61 | range.setStart($p.firstChild, 3); 62 | range.setEnd($p.childNodes.item(3).firstChild, 5); 63 | var sel = window.getSelection(); 64 | sel.addRange(range); 65 | var res = WebVerse.serializeSelection(); 66 | assert.equal(res.$scope, $p, 'defaulted the scope'); 67 | assert.equal(res.hash, hashedHW, 'hashed the content properly'); 68 | assert.equal(res.key, 'hwhw', 'generates the correct key'); 69 | assert.equal(res.startOffset, 0, 'normalised the start offset'); 70 | assert.equal(res.endOffset, 11, 'normalised the end offset'); 71 | }); 72 | }); 73 | })(); 74 | --------------------------------------------------------------------------------