├── .jshintrc ├── Gulpfile.js ├── LICENSE.md ├── Procfile.dev ├── README.md ├── bower.json ├── dist └── curse.js ├── examples ├── .jshintrc ├── basic.js ├── curse.js ├── index.html └── styles.css ├── package.json ├── src └── curse.js ├── test ├── .jshintrc └── curse-test.js └── testem.json /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "-W078" : true, 3 | "browser" : true, 4 | "camelcase" : true, 5 | "curly" : true, 6 | "eqeqeq" : true, 7 | "esnext" : true, 8 | "forin" : true, 9 | "freeze" : true, 10 | "immed" : true, 11 | "indent" : 2, 12 | "maxcomplexity": 12, 13 | "maxdepth" : 3, 14 | "maxlen" : 80, 15 | "maxparams" : 4, 16 | "maxstatements": 48, 17 | "newcap" : true, 18 | "noarg" : true, 19 | "noempty" : true, 20 | "nonbsp" : true, 21 | "nonew" : true, 22 | "quotmark" : "single", 23 | "undef" : true, 24 | "unused" : true 25 | } 26 | -------------------------------------------------------------------------------- /Gulpfile.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | 3 | 'use strict'; 4 | 5 | var browserify = require('browserify'); 6 | var buffer = require('vinyl-buffer'); 7 | var gulp = require('gulp'); 8 | var source = require('vinyl-source-stream'); 9 | 10 | require('6to5ify'); 11 | 12 | gulp.task('build', function build() { 13 | var bundler = browserify({ 14 | entries : ['./src/curse.js'], 15 | standalone: 'Curse', 16 | transform : ['6to5ify'] 17 | }); 18 | 19 | var bundle = bundler.bundle(); 20 | 21 | bundle 22 | .pipe(source('curse.js')) 23 | .pipe(buffer()) 24 | .pipe(gulp.dest('dist')); 25 | }); 26 | 27 | gulp.task('copy-example', ['build'], function copyExample() { 28 | gulp.src('dist/curse.js') 29 | .pipe(gulp.dest('examples')); 30 | }); 31 | 32 | gulp.task('watch', function() { 33 | gulp.watch('src/curse.js', ['copy-example']); 34 | }); 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Jonathan Clem 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | gulp: gulp watch 2 | harp: harp server examples 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Curse 2 | 3 | Curse is a library for capturing and restoring selections—primarily for use in 4 | HTML elements that are `contenteditable`, where text may stay the same, but 5 | the rendered HTML may change. 6 | 7 | ![Demonstration](https://dl.dropboxusercontent.com/s/0ekgujm9vihn2rd/curse.gif?raw=1) 8 | 9 | ## Install 10 | 11 | ```sh 12 | bower install curse 13 | ``` 14 | 15 | ## Usage 16 | 17 | Create a new `Curse` and pass it an `HTMLElement`. The curse is capable of 18 | capturing and restoring the user's selection inside of that element. 19 | 20 | ```javascript 21 | var element = document.querySelector('#editor'); 22 | var curse = new Curse(element); 23 | 24 | element.innerText = 'Hello world'; 25 | 26 | // User selects "llo w" 27 | 28 | curse.capture(); // Capture the current cursor or selection 29 | 30 | element.innerText = 'Hello, world'; 31 | 32 | curse.restore(); // Restore the user's selection. 33 | ``` 34 | 35 | Note that a `Curse` is dumb. In the above example, if the user's selection was 36 | `"llo w"`, after the text was changed and the cursor restored, the user's 37 | selection would have been `"llo, "`. It is up to the implementer to handle 38 | changes in text length by adjusting `curse.start` and `curse.end`: 39 | 40 | ```javascript 41 | element.innerText = 'Hello world'; 42 | // User selects "llo w" 43 | curse.capture(); 44 | element.innerText = 'Hello, world'; 45 | curse.end++; 46 | curse.restore(); // Selection is "llo, w" 47 | ``` 48 | 49 | It's possible that depending on your setup, you may need to pass a custom 50 | function to a Curse to count node length. This can be done by passing 51 | `nodeLengthFn`: 52 | 53 | ```javascript 54 | let curse = new Curse(element, { nodeLengthFn: nodeLengthFn }); 55 | 56 | function nodeLengthFn(node, __super) { 57 | if (node.classList.contains('Foo')) { 58 | return 12; 59 | } else { 60 | /* 61 | * `__super` can be called to call the original `nodeLength` function on the 62 | * given node. 63 | */ 64 | return __super(); 65 | } 66 | } 67 | ``` 68 | 69 | Curse still gets a little confused when it sees certain types of HTML elements 70 | in a contenteditable. If you run across something, please [open an 71 | issue](https://github.com/slowink/curse/issues). 72 | 73 | ## Development 74 | 75 | Tests use [testem](https://github.com/airportyh/testem) and run in the Chrome 76 | browser. 77 | 78 | Install: 79 | 80 | ```sh 81 | git clone git@github.com:slowink/curse.git 82 | cd curse 83 | npm install 84 | ``` 85 | 86 | Test: 87 | 88 | ```sh 89 | npm test 90 | ``` 91 | 92 | In order to run the example page (useful for experimentation): 93 | 94 | ```sh 95 | npm run dev 96 | ``` 97 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "curse", 3 | "version": "3.1.2", 4 | "authors": [ 5 | "Jonathan Clem " 6 | ], 7 | "description": "manage cursor position in a contenteditable element", 8 | "main": "dist/curse.js", 9 | "moduleType": [ 10 | "globals" 11 | ], 12 | "keywords": [ 13 | "cursor", 14 | "contenteditable" 15 | ], 16 | "license": "MIT", 17 | "homepage": "https://github.com/slowink/curse", 18 | "ignore": [ 19 | "**/.*", 20 | "examples", 21 | "node_modules", 22 | "test", 23 | "Gulpfile.js", 24 | "package.json", 25 | "Procfile.dev", 26 | "testem.json" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /dist/curse.js: -------------------------------------------------------------------------------- 1 | !function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.Curse=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o` tag, for example, is counted as a single "character", while 46 | * a text node with the text "Hello, world" is counted as 12 characters. 47 | * 48 | * @method capture 49 | */ 50 | value: function capture() { 51 | var _window$getSelection = window.getSelection(); 52 | 53 | var anchorNode = _window$getSelection.anchorNode; 54 | var anchorOffset = _window$getSelection.anchorOffset; 55 | var focusNode = _window$getSelection.focusNode; 56 | var focusOffset = _window$getSelection.focusOffset; 57 | var child = undefined, 58 | start = undefined, 59 | end = undefined; 60 | 61 | if (anchorNode === null || focusNode === null) { 62 | this.reset(); 63 | return { start: start, end: end }; 64 | } 65 | 66 | if (anchorNode.nodeName === "#text") { 67 | start = this.lengthUpTo(anchorNode) + anchorOffset; 68 | } else { 69 | child = anchorNode.childNodes[anchorOffset - 1]; 70 | 71 | if (child) { 72 | start = this.lengthUpTo(child) + this.nodeLength(child, false, true); 73 | } else { 74 | start = this.lengthUpTo(anchorNode); 75 | } 76 | } 77 | 78 | if (focusNode.nodeName === "#text") { 79 | end = this.lengthUpTo(focusNode) + focusOffset; 80 | } else { 81 | child = focusNode.childNodes[focusOffset - 1]; 82 | 83 | if (child) { 84 | end = this.lengthUpTo(child) + this.nodeLength(child, false, true); 85 | } else { 86 | end = this.lengthUpTo(focusNode); 87 | } 88 | } 89 | 90 | this.start = start; 91 | this.end = end; 92 | 93 | return { start: start, end: end }; 94 | }, 95 | writable: true, 96 | enumerable: true, 97 | configurable: true 98 | }, 99 | restore: { 100 | 101 | /** 102 | * Restore the captured cursor state by iterating over the child nodes in the 103 | * element and counting their length. 104 | * 105 | * @method restore 106 | * @param {Boolean} [onlyActive=true] only restore if the curse's element is 107 | * the active element 108 | */ 109 | value: function restore() { 110 | var onlyActive = arguments[0] === undefined ? true : arguments[0]; 111 | var _ref2 = arguments[1] === undefined ? this : arguments[1]; 112 | var start = _ref2.start; 113 | var end = _ref2.end; 114 | if (onlyActive && document.activeElement !== this.element) { 115 | return; 116 | } 117 | 118 | if (!onlyActive) { 119 | this.element.focus(); 120 | } 121 | 122 | var range = document.createRange(); 123 | var idx = 0; 124 | var iter = this.getIterator(this.element); 125 | var node = undefined, 126 | setStart = undefined, 127 | setEnd = undefined; 128 | 129 | if (this.start > this.end) { 130 | start = this.end; 131 | end = this.start; 132 | } 133 | 134 | while (node = iter.nextNode()) { 135 | if (setStart && setEnd) { 136 | break; 137 | } 138 | 139 | var nodeLength = this.nodeLength(node); 140 | var isText = node.nodeName === "#text"; 141 | var childIdx = undefined; 142 | 143 | if (!setStart && start <= idx + nodeLength) { 144 | this.indexOfNode(node); 145 | if (isText) { 146 | range.setStart(node, start - idx); 147 | } else { 148 | childIdx = this.indexOfNode(node); 149 | range.setStart(node.parentNode, childIdx); 150 | } 151 | 152 | setStart = true; 153 | } 154 | 155 | if (!setEnd && end <= idx + nodeLength) { 156 | if (isText) { 157 | range.setEnd(node, end - idx); 158 | } else { 159 | childIdx = this.indexOfNode(node); 160 | range.setEnd(node.parentNode, childIdx); 161 | } 162 | 163 | setEnd = true; 164 | } 165 | 166 | idx += nodeLength; 167 | } 168 | 169 | var selection = window.getSelection(); 170 | selection.removeAllRanges(); 171 | 172 | // Reverse the selection if it was originally backwards 173 | if (this.start > this.end) { 174 | var startContainer = range.startContainer; 175 | var startOffset = range.startOffset; 176 | range.collapse(false); 177 | selection.addRange(range); 178 | selection.extend(startContainer, startOffset); 179 | } else { 180 | selection.addRange(range); 181 | } 182 | }, 183 | writable: true, 184 | enumerable: true, 185 | configurable: true 186 | }, 187 | getIterator: { 188 | 189 | /** 190 | * Get an iterator to iterate over the child nodes in a given node. 191 | * 192 | * @method getIterator 193 | * @private 194 | * @param {Node} iteratee The node to iterate through the children of 195 | * @return {NodeIterator} 196 | */ 197 | value: function getIterator(iteratee) { 198 | return document.createNodeIterator(iteratee, NodeFilter.SHOW_ALL, function onNode(node) { 199 | return node === iteratee ? NodeFilter.FILTER_SKIP : NodeFilter.FILTER_ACCEPT; 200 | }); 201 | }, 202 | writable: true, 203 | enumerable: true, 204 | configurable: true 205 | }, 206 | indexOfNode: { 207 | 208 | /** 209 | * Get the index of a node in its parent node. 210 | * 211 | * @method indexOfNode 212 | * @private 213 | * @param {Node} child the child node to find the index of 214 | * @returns {Number} the index of the child node 215 | */ 216 | value: function indexOfNode(child) { 217 | var i = 0; 218 | 219 | while (child = child.previousSibling) { 220 | i++; 221 | } 222 | 223 | return i; 224 | }, 225 | writable: true, 226 | enumerable: true, 227 | configurable: true 228 | }, 229 | lengthUpTo: { 230 | 231 | /** 232 | * Get the number of characters up to a given node. 233 | * 234 | * @method lengthUpTo 235 | * @private 236 | * @param {Node} node a node to find the length up to 237 | * @returns {Number} the length up to the given node 238 | */ 239 | value: function lengthUpTo(lastNode) { 240 | var len = 0; 241 | var iter = this.getIterator(this.element); 242 | var node = undefined; 243 | 244 | while (node = iter.nextNode()) { 245 | if (node === lastNode) { 246 | break; 247 | } 248 | 249 | len += this.nodeLength(node); 250 | } 251 | 252 | return len; 253 | }, 254 | writable: true, 255 | enumerable: true, 256 | configurable: true 257 | }, 258 | reset: { 259 | 260 | /** 261 | * Reset the state of the cursor. 262 | * 263 | * @method reset 264 | * @private 265 | */ 266 | value: function reset() { 267 | this.start = null; 268 | this.end = null; 269 | }, 270 | writable: true, 271 | enumerable: true, 272 | configurable: true 273 | }, 274 | nodeLength: { 275 | 276 | /** 277 | * Get the "length" of a node. Text nodes are the length of their contents, 278 | * and `
` tags, for example, have a length of 1 (a newline character, 279 | * essentially). 280 | * 281 | * @method nodeLength 282 | * @private 283 | * @param {Node} node A Node, typically a Text or HTMLElement node 284 | * @param {Bool} ignoreNodeLengthFn Ignore the custom nodeLengthFn 285 | * @param {Bool} recurse Recurse through the node to get its length 286 | * @return {Number} The length of the node, as text 287 | */ 288 | value: function nodeLength(node) { 289 | var _this = this; 290 | var ignoreNodeLengthFn = arguments[1] === undefined ? false : arguments[1]; 291 | var recurse = arguments[2] === undefined ? false : arguments[2]; 292 | if (this.nodeLengthFn && !ignoreNodeLengthFn) { 293 | var _ret = (function () { 294 | var nodeLength = _this.nodeLength.bind(_this); 295 | 296 | return { 297 | v: _this.nodeLengthFn(node, function __super() { 298 | return nodeLength(node, true, recurse); 299 | }) 300 | }; 301 | })(); 302 | 303 | if (typeof _ret === "object") return _ret.v; 304 | } 305 | 306 | if (recurse && node.childNodes.length) { 307 | var iter = this.getIterator(node); 308 | 309 | var innerLength = 0; 310 | var childNode = undefined; 311 | 312 | while (childNode = iter.nextNode()) { 313 | innerLength += this.nodeLength(childNode, ignoreNodeLengthFn, recurse); 314 | } 315 | 316 | return innerLength; 317 | } 318 | 319 | var charNodes = ["BR", "HR", "IMG"]; 320 | 321 | if (node.nodeName === "#text") { 322 | return node.data.length; 323 | } else if (charNodes.indexOf(node.nodeName) > -1) { 324 | return 1; 325 | } else { 326 | return 0; 327 | } 328 | }, 329 | writable: true, 330 | enumerable: true, 331 | configurable: true 332 | }, 333 | offset: { 334 | 335 | /** 336 | * Offset the cursor position's start and end. 337 | * 338 | * @method offset 339 | * @param {Number} startOffset the offset for the curse start 340 | * @param {Number} endOffset the offset for the curse end 341 | */ 342 | value: function offset() { 343 | var _this2 = this; 344 | var startOffset = arguments[0] === undefined ? 0 : arguments[0]; 345 | var endOffset = arguments[1] === undefined ? startOffset : arguments[1]; 346 | return (function () { 347 | _this2.start += startOffset; 348 | _this2.end += endOffset; 349 | })(); 350 | }, 351 | writable: true, 352 | enumerable: true, 353 | configurable: true 354 | } 355 | }); 356 | 357 | return Curse; 358 | })(); 359 | 360 | module.exports = Curse; 361 | 362 | 363 | },{}]},{},[1])(1) 364 | }); -------------------------------------------------------------------------------- /examples/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "browser": true, 3 | "unused": true 4 | } 5 | -------------------------------------------------------------------------------- /examples/basic.js: -------------------------------------------------------------------------------- 1 | var capture = document.querySelector('button.capture'); 2 | var restore = document.querySelector('button.restore'); 3 | var editor = document.querySelector('.editor'); 4 | var start = document.querySelector('td.start'); 5 | var end = document.querySelector('td.end'); 6 | var startN = document.querySelector('td.start-node'); 7 | var endN = document.querySelector('td.end-node'); 8 | var startO = document.querySelector('td.start-offset'); 9 | var endO = document.querySelector('td.end-offset'); 10 | var curse = new Curse(editor); 11 | 12 | capture.addEventListener('click', function onClick() { 13 | curse.capture(); 14 | }); 15 | 16 | restore.addEventListener('click', function onRestore() { 17 | curse.restore(false); 18 | }); 19 | 20 | Object.observe(curse, function onChange() { 21 | var sel = window.getSelection(); 22 | 23 | startN.innerText = sel.anchorNode; 24 | startO.innerText = sel.anchorOffset; 25 | 26 | endN.innerText = sel.focusNode; 27 | endO.innerText = sel.focusOffset; 28 | 29 | start.innerText = curse.start; 30 | end.innerText = curse.end; 31 | }); 32 | -------------------------------------------------------------------------------- /examples/curse.js: -------------------------------------------------------------------------------- 1 | !function(e){if("object"==typeof exports)module.exports=e();else if("function"==typeof define&&define.amd)define(e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.Curse=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);throw new Error("Cannot find module '"+o+"'")}var f=n[o]={exports:{}};t[o][0].call(f.exports,function(e){var n=t[o][1][e];return s(n?n:e)},f,f.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o` tag, for example, is counted as a single "character", while 65 | * a text node with the text "Hello, world" is counted as 12 characters. 66 | * 67 | * @method capture 68 | */ 69 | value: function capture() { 70 | var _window$getSelection = window.getSelection(); 71 | 72 | var anchorNode = _window$getSelection.anchorNode; 73 | var anchorOffset = _window$getSelection.anchorOffset; 74 | var focusNode = _window$getSelection.focusNode; 75 | var focusOffset = _window$getSelection.focusOffset; 76 | var child = undefined, 77 | start = undefined, 78 | end = undefined; 79 | 80 | if (anchorNode === null || focusNode === null) { 81 | this.reset(); 82 | return; 83 | } 84 | 85 | if (anchorNode.nodeName === "#text") { 86 | start = this.lengthUpTo(anchorNode) + anchorOffset; 87 | } else { 88 | child = anchorNode.childNodes[anchorOffset]; 89 | start = this.lengthUpTo(child); 90 | } 91 | 92 | if (focusNode.nodeName === "#text") { 93 | end = this.lengthUpTo(focusNode) + focusOffset; 94 | } else { 95 | child = focusNode.childNodes[focusOffset]; 96 | end = this.lengthUpTo(child) + this.nodeLength(child); 97 | } 98 | 99 | this.start = start; 100 | this.end = end; 101 | }, 102 | writable: true, 103 | enumerable: true, 104 | configurable: true 105 | }, 106 | restore: { 107 | 108 | /** 109 | * Restore the captured cursor state by iterating over the child nodes in the 110 | * element and counting their length. 111 | * 112 | * @method restore 113 | * @param {Boolean} [onlyActive=true] only restore if the curse's element is 114 | * the active element 115 | */ 116 | value: function restore() { 117 | var onlyActive = arguments[0] === undefined ? true : arguments[0]; 118 | if (onlyActive && document.activeElement !== this.element) { 119 | return; 120 | } 121 | 122 | if (!onlyActive) { 123 | this.element.focus(); 124 | } 125 | 126 | var range = document.createRange(); 127 | var idx = 0; 128 | var _ref = this; 129 | var start = _ref.start; 130 | var end = _ref.end; 131 | var iter = this.iterator; 132 | var node = undefined, 133 | setStart = undefined, 134 | setEnd = undefined; 135 | 136 | if (this.start > this.end) { 137 | start = this.end; 138 | end = this.start; 139 | } 140 | 141 | while (node = iter.nextNode()) { 142 | if (setStart && setEnd) { 143 | break; 144 | } 145 | 146 | var nodeLength = this.nodeLength(node); 147 | var isText = node.nodeName === "#text"; 148 | var childIdx = undefined; 149 | 150 | if (!setStart && start <= idx + nodeLength) { 151 | if (isText) { 152 | range.setStart(node, start - idx); 153 | } else { 154 | childIdx = this.indexOfNode(node); 155 | range.setStart(node.parentNode, childIdx); 156 | } 157 | 158 | setStart = true; 159 | } 160 | 161 | if (!setEnd && end <= idx + nodeLength) { 162 | if (isText) { 163 | range.setEnd(node, end - idx); 164 | } else { 165 | childIdx = this.indexOfNode(node); 166 | range.setEnd(node.parentNode, childIdx); 167 | } 168 | 169 | setEnd = true; 170 | } 171 | 172 | idx += nodeLength; 173 | } 174 | 175 | var selection = window.getSelection(); 176 | selection.removeAllRanges(); 177 | 178 | // Reverse the selection if it was originally backwards 179 | if (this.start > this.end) { 180 | var startContainer = range.startContainer; 181 | var startOffset = range.startOffset; 182 | range.collapse(false); 183 | selection.addRange(range); 184 | selection.extend(startContainer, startOffset); 185 | } else { 186 | selection.addRange(range); 187 | } 188 | }, 189 | writable: true, 190 | enumerable: true, 191 | configurable: true 192 | }, 193 | indexOfNode: { 194 | 195 | /** 196 | * Get the index of a node in its parent node. 197 | * 198 | * @method indexOfNode 199 | * @private 200 | * @param {Node} child the child node to find the index of 201 | * @returns {Number} the index of the child node 202 | */ 203 | value: function indexOfNode(child) { 204 | var i = 0; 205 | 206 | while (child = child.previousSibling) { 207 | i++; 208 | } 209 | 210 | return i; 211 | }, 212 | writable: true, 213 | enumerable: true, 214 | configurable: true 215 | }, 216 | lengthUpTo: { 217 | 218 | /** 219 | * Get the number of characters up to a given node. 220 | * 221 | * @method lengthUpTo 222 | * @private 223 | * @param {Node} node a node to find the length up to 224 | * @returns {Number} the length up to the given node 225 | */ 226 | value: function lengthUpTo(lastNode) { 227 | var len = 0; 228 | var iter = this.iterator; 229 | var node = undefined; 230 | 231 | while (node = iter.nextNode()) { 232 | if (node === lastNode) { 233 | break; 234 | } 235 | 236 | len += this.nodeLength(node); 237 | } 238 | 239 | return len; 240 | }, 241 | writable: true, 242 | enumerable: true, 243 | configurable: true 244 | }, 245 | reset: { 246 | 247 | /** 248 | * Reset the state of the cursor. 249 | * 250 | * @method reset 251 | * @private 252 | */ 253 | value: function reset() { 254 | this.start = null; 255 | this.end = null; 256 | }, 257 | writable: true, 258 | enumerable: true, 259 | configurable: true 260 | }, 261 | nodeLength: { 262 | 263 | /** 264 | * Get the "length" of a node. Text nodes are the length of their contents, 265 | * and `
` tags, for example, have a length of 1 (a newline character, 266 | * essentially). 267 | * 268 | * @method nodeLength 269 | * @private 270 | * @param {Node} node a Node, typically a Text or HTMLElement node 271 | * @return {Number} the length of the node, as text 272 | */ 273 | value: function nodeLength(node) { 274 | var charNodes = ["BR", "HR", "IMG"]; 275 | 276 | if (node.nodeName === "#text") { 277 | return node.data.length; 278 | } else if (charNodes.indexOf(node.nodeName) > -1) { 279 | return 1; 280 | } else { 281 | return 0; 282 | } 283 | }, 284 | writable: true, 285 | enumerable: true, 286 | configurable: true 287 | } 288 | }); 289 | 290 | return Curse; 291 | })(); 292 | 293 | module.exports = Curse; 294 | 295 | },{}]},{},[1]) 296 | (1) 297 | }); -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Curse | Basic 6 | 7 | 8 | 9 | 10 |

Curse

11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
startend
Cursenullnull
Nodenullnull
Offsetnullnull
44 | 45 |
46 |

Curses.

47 | 48 | Captain Ron 51 | 52 |
class Curse() {
53 |   constructor(element) {
54 |     this.element = element;
55 |   }
56 | }
57 |
58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /examples/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box 3 | } 4 | 5 | body { 6 | padding: 80px; 7 | max-width: 800px; 8 | width: 100%; 9 | 10 | color: #333; 11 | font-family: sans-serif; 12 | } 13 | 14 | h2 { 15 | color: #666; 16 | font-weight: normal; 17 | } 18 | 19 | table { 20 | margin-top: 20px; 21 | border-collapse: collapse; 22 | } 23 | 24 | th, td { 25 | border: 1px solid #ccc; 26 | font-family: monospace; 27 | padding: 4px 10px; 28 | text-align: center; 29 | } 30 | 31 | .editor { 32 | width: 100%; 33 | 34 | border: 1px solid #ccc; 35 | margin: 20px auto 0 auto; 36 | outline: none; 37 | padding: 0 20px; 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cursejs", 3 | "description": "A user selection caching utility", 4 | "version": "3.2.0", 5 | "author": "Jonathan Clem", 6 | "bugs": { 7 | "url": "https://github.com/slowink/curse/issues" 8 | }, 9 | "devDependencies": { 10 | "6to5ify": "^3.1.2", 11 | "browserify": "^8.1.3", 12 | "foreman": "^1.1.0", 13 | "gulp": "^3.8.10", 14 | "harp": "^0.14.0", 15 | "mocha": "^2.0.1", 16 | "should": "^4.1.0", 17 | "testem": "^0.6.22", 18 | "vinyl-buffer": "^1.0.0", 19 | "vinyl-source-stream": "^1.0.0" 20 | }, 21 | "directories": { 22 | "example": "examples", 23 | "test": "test" 24 | }, 25 | "homepage": "https://github.com/slowink/curse", 26 | "keywords": [ 27 | "cursor" 28 | ], 29 | "license": "MIT", 30 | "main": "dist/curse.js", 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/slowink/curse.git" 34 | }, 35 | "scripts": { 36 | "build": "gulp build", 37 | "dev": "nf start -j Procfile.dev", 38 | "test": "testem" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/curse.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Captures and restores the cursor position inside of an HTML element. This 3 | * is particularly useful for capturing, editing, and restoring selections 4 | * in contenteditable elements, where the selection may span both text nodes 5 | * and element nodes. 6 | * 7 | * var elem = document.querySelector('#editor'); 8 | * var curse = new Curse(elem); 9 | * curse.capture(); 10 | * 11 | * // ... 12 | * 13 | * curse.restore(); 14 | * 15 | * @class Curse 16 | * @constructor 17 | * @param {HTMLElement} element an element to track the cursor inside of 18 | */ 19 | export default class Curse { 20 | constructor(element, { nodeLengthFn } = {}) { 21 | this.element = element; 22 | this.nodeLengthFn = nodeLengthFn; 23 | this.reset(); 24 | } 25 | 26 | /** 27 | * Captures the current selection in the element, by storing "start" and 28 | * "end" integer values. This allows for the text and text nodes to change 29 | * inside the element and still have the Curse be able to restore selection 30 | * state. 31 | * 32 | * A `
` tag, for example, is counted as a single "character", while 33 | * a text node with the text "Hello, world" is counted as 12 characters. 34 | * 35 | * @method capture 36 | */ 37 | capture() { 38 | let { anchorNode, anchorOffset, focusNode, focusOffset } 39 | = window.getSelection(); 40 | let child, start, end; 41 | 42 | if (anchorNode === null || focusNode === null) { 43 | this.reset(); 44 | return { start, end }; 45 | } 46 | 47 | if (anchorNode.nodeName === '#text') { 48 | start = this.lengthUpTo(anchorNode) + anchorOffset; 49 | } else { 50 | child = anchorNode.childNodes[anchorOffset - 1]; 51 | 52 | if (child) { 53 | start = this.lengthUpTo(child) + this.nodeLength(child, false, true); 54 | } else { 55 | start = this.lengthUpTo(anchorNode); 56 | } 57 | } 58 | 59 | if (focusNode.nodeName === '#text') { 60 | end = this.lengthUpTo(focusNode) + focusOffset; 61 | } else { 62 | child = focusNode.childNodes[focusOffset - 1]; 63 | 64 | if (child) { 65 | end = this.lengthUpTo(child) + this.nodeLength(child, false, true); 66 | } else { 67 | end = this.lengthUpTo(focusNode); 68 | } 69 | } 70 | 71 | this.start = start; 72 | this.end = end; 73 | 74 | return { start, end }; 75 | } 76 | 77 | /** 78 | * Restore the captured cursor state by iterating over the child nodes in the 79 | * element and counting their length. 80 | * 81 | * @method restore 82 | * @param {Boolean} [onlyActive=true] only restore if the curse's element is 83 | * the active element 84 | */ 85 | restore(onlyActive = true, { start, end } = this) { 86 | if (onlyActive && document.activeElement !== this.element) { 87 | return; 88 | } 89 | 90 | if (!onlyActive) { 91 | this.element.focus(); 92 | } 93 | 94 | let range = document.createRange(); 95 | let idx = 0; 96 | let iter = this.getIterator(this.element); 97 | let node, setStart, setEnd; 98 | 99 | if (this.start > this.end) { 100 | start = this.end; 101 | end = this.start; 102 | } 103 | 104 | while ((node = iter.nextNode())) { 105 | if (setStart && setEnd) { 106 | break; 107 | } 108 | 109 | let nodeLength = this.nodeLength(node); 110 | let isText = node.nodeName === '#text'; 111 | let childIdx; 112 | 113 | if (!setStart && start <= idx + nodeLength) { 114 | this.indexOfNode(node); 115 | if (isText) { 116 | range.setStart(node, start - idx); 117 | } else { 118 | childIdx = this.indexOfNode(node); 119 | range.setStart(node.parentNode, childIdx); 120 | } 121 | 122 | setStart = true; 123 | } 124 | 125 | if (!setEnd && end <= idx + nodeLength) { 126 | if (isText) { 127 | range.setEnd(node, end - idx); 128 | } else { 129 | childIdx = this.indexOfNode(node); 130 | range.setEnd(node.parentNode, childIdx); 131 | } 132 | 133 | setEnd = true; 134 | } 135 | 136 | idx += nodeLength; 137 | } 138 | 139 | let selection = window.getSelection(); 140 | selection.removeAllRanges(); 141 | 142 | // Reverse the selection if it was originally backwards 143 | if (this.start > this.end) { 144 | let { startContainer, startOffset } = range; 145 | range.collapse(false); 146 | selection.addRange(range); 147 | selection.extend(startContainer, startOffset); 148 | } else { 149 | selection.addRange(range); 150 | } 151 | } 152 | 153 | /** 154 | * Get an iterator to iterate over the child nodes in a given node. 155 | * 156 | * @method getIterator 157 | * @private 158 | * @param {Node} iteratee The node to iterate through the children of 159 | * @return {NodeIterator} 160 | */ 161 | getIterator(iteratee) { 162 | return document.createNodeIterator( 163 | iteratee, 164 | 165 | NodeFilter.SHOW_ALL, 166 | 167 | function onNode(node) { 168 | return node === iteratee ? 169 | NodeFilter.FILTER_SKIP : NodeFilter.FILTER_ACCEPT; 170 | } 171 | ); 172 | } 173 | 174 | /** 175 | * Get the index of a node in its parent node. 176 | * 177 | * @method indexOfNode 178 | * @private 179 | * @param {Node} child the child node to find the index of 180 | * @returns {Number} the index of the child node 181 | */ 182 | indexOfNode(child) { 183 | let i = 0; 184 | 185 | while ((child = child.previousSibling)) { 186 | i++; 187 | } 188 | 189 | return i; 190 | } 191 | 192 | /** 193 | * Get the number of characters up to a given node. 194 | * 195 | * @method lengthUpTo 196 | * @private 197 | * @param {Node} node a node to find the length up to 198 | * @returns {Number} the length up to the given node 199 | */ 200 | lengthUpTo(lastNode) { 201 | let len = 0; 202 | let iter = this.getIterator(this.element); 203 | let node; 204 | 205 | while ((node = iter.nextNode())) { 206 | if (node === lastNode) { 207 | break; 208 | } 209 | 210 | len += this.nodeLength(node); 211 | } 212 | 213 | return len; 214 | } 215 | 216 | /** 217 | * Reset the state of the cursor. 218 | * 219 | * @method reset 220 | * @private 221 | */ 222 | reset() { 223 | this.start = null; 224 | this.end = null; 225 | } 226 | 227 | /** 228 | * Get the "length" of a node. Text nodes are the length of their contents, 229 | * and `
` tags, for example, have a length of 1 (a newline character, 230 | * essentially). 231 | * 232 | * @method nodeLength 233 | * @private 234 | * @param {Node} node A Node, typically a Text or HTMLElement node 235 | * @param {Bool} ignoreNodeLengthFn Ignore the custom nodeLengthFn 236 | * @param {Bool} recurse Recurse through the node to get its length 237 | * @return {Number} The length of the node, as text 238 | */ 239 | nodeLength(node, ignoreNodeLengthFn = false, recurse = false) { 240 | if (this.nodeLengthFn && !ignoreNodeLengthFn) { 241 | const nodeLength = this.nodeLength.bind(this); 242 | 243 | return this.nodeLengthFn(node, function __super() { 244 | return nodeLength(node, true, recurse); 245 | }); 246 | } 247 | 248 | if (recurse && node.childNodes.length) { 249 | const iter = this.getIterator(node); 250 | 251 | let innerLength = 0; 252 | let childNode; 253 | 254 | while ((childNode = iter.nextNode())) { 255 | innerLength += this.nodeLength(childNode, ignoreNodeLengthFn, recurse); 256 | } 257 | 258 | return innerLength; 259 | } 260 | 261 | let charNodes = ['BR', 'HR', 'IMG']; 262 | 263 | if (node.nodeName === '#text') { 264 | return node.data.length; 265 | } else if (charNodes.indexOf(node.nodeName) > -1) { 266 | return 1; 267 | } else { 268 | return 0; 269 | } 270 | } 271 | 272 | /** 273 | * Offset the cursor position's start and end. 274 | * 275 | * @method offset 276 | * @param {Number} startOffset the offset for the curse start 277 | * @param {Number} endOffset the offset for the curse end 278 | */ 279 | offset(startOffset = 0, endOffset = startOffset) { 280 | this.start += startOffset; 281 | this.end += endOffset; 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /test/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "browser": true, 3 | "mocha": true, 4 | "unused": true 5 | } 6 | -------------------------------------------------------------------------------- /test/curse-test.js: -------------------------------------------------------------------------------- 1 | describe('Curse', function() { 2 | var $e, curse; 3 | 4 | beforeEach(function() { 5 | $e = document.createElement('div'); 6 | curse = new Curse($e); 7 | 8 | $e.setAttribute('contenteditable', true); 9 | document.body.appendChild($e); 10 | $e.focus(); 11 | }); 12 | 13 | afterEach(function() { 14 | $e.remove(); 15 | }); 16 | 17 | describe('capturing and restoring over plaintext', function() { 18 | beforeEach(function() { 19 | $e.innerText = 'foo bar'; 20 | var range = createRange([$e.childNodes[0], 2], [$e.childNodes[0], 6]); 21 | addRange(range); 22 | assertSelected('o ba'); 23 | curse.capture(); 24 | }); 25 | 26 | it('can capture a selection', function() { 27 | [curse.start, curse.end].should.eql([2, 6]); 28 | }); 29 | 30 | it('can restore a selection', function() { 31 | window.getSelection().removeAllRanges(); 32 | curse.restore(); 33 | assertSelected('o ba'); 34 | }); 35 | 36 | it('can offset a selection', function() { 37 | window.getSelection().removeAllRanges(); 38 | curse.offset(1); 39 | curse.restore(); 40 | assertSelected(' bar'); 41 | }); 42 | 43 | it('can accept a custom node length function', function() { 44 | curse = new Curse($e, { nodeLengthFn: function nodeLength(/* node, __super */) { 45 | return 'CUSTOM'; 46 | } }); 47 | 48 | curse.nodeLength($e.firstChild).should.equal('CUSTOM'); 49 | }); 50 | }); 51 | 52 | describe('capturing and restoring a backwards selection', function() { 53 | beforeEach(function() { 54 | $e.innerText = 'foo bar'; 55 | var range = createRange([$e.childNodes[0], 2], [$e.childNodes[0], 6]); 56 | addRange(range, true); 57 | assertSelected('o ba'); 58 | curse.capture(); 59 | }); 60 | 61 | it('can capture a selection', function() { 62 | [curse.start, curse.end].should.eql([6, 2]); 63 | }); 64 | 65 | it('can restore a selection', function() { 66 | var sel = window.getSelection(); 67 | sel.removeAllRanges(); 68 | curse.restore(); 69 | sel.anchorOffset.should.eql(6); 70 | sel.focusOffset.should.eql(2); 71 | assertSelected('o ba'); 72 | }); 73 | }); 74 | 75 | describe('capturing and restoring spanning an HTML element', function() { 76 | beforeEach(function() { 77 | $e.innerHTML = 'foo bar baz'; 78 | var range = createRange([$e.childNodes[0], 2], [$e.childNodes[2], 2]); 79 | addRange(range); 80 | assertSelected('o bar b'); 81 | curse.capture(); 82 | }); 83 | 84 | it('can capture the selection', function() { 85 | [curse.start, curse.end].should.eql([2, 9]); 86 | }); 87 | 88 | it('can restore the selection', function() { 89 | window.getSelection().removeAllRanges(); 90 | curse.restore(); 91 | assertSelected('o bar b'); 92 | }); 93 | }); 94 | 95 | describe('capturing and restoring spanning a newline', function() { 96 | beforeEach(function() { 97 | $e.innerText = 'foo\nbar\nbaz'; 98 | var range = createRange([$e.childNodes[0], 1], [$e.childNodes[4], 1]); 99 | addRange(range); 100 | assertSelected('oo\nbar\nb'); 101 | curse.capture(); 102 | }); 103 | 104 | it('can capture the selection', function() { 105 | [curse.start, curse.end].should.eql([1, 9]); 106 | }); 107 | 108 | it('can restore the selection', function() { 109 | window.getSelection().removeAllRanges(); 110 | curse.restore(); 111 | assertSelected('oo\nbar\nb'); 112 | }); 113 | }); 114 | }); 115 | 116 | function addRange(range, reverse) { 117 | var sel = window.getSelection(); 118 | sel.removeAllRanges(); 119 | 120 | if (reverse) { 121 | var startC = range.startContainer; 122 | var startO = range.startOffset; 123 | range.collapse(false); 124 | sel.addRange(range); 125 | sel.extend(startC, startO); 126 | } else { 127 | sel.addRange(range); 128 | } 129 | } 130 | 131 | function assertSelected(text) { 132 | window.getSelection().toString().should.eql(text); 133 | } 134 | 135 | function createRange(anchor, focus) { 136 | var r = document.createRange(); 137 | r.setStart(anchor[0], anchor[1]); 138 | r.setEnd(focus[0], focus[1]); 139 | return r; 140 | } 141 | -------------------------------------------------------------------------------- /testem.json: -------------------------------------------------------------------------------- 1 | { 2 | "framework": "mocha", 3 | "src_files": [ 4 | "./dist/curse.js", 5 | "./node_modules/should/should.js", 6 | "./test/*-test.js" 7 | ] 8 | } 9 | --------------------------------------------------------------------------------