├── history.md ├── index.js ├── .gitignore ├── Makefile ├── profile └── html-parser.js ├── test ├── index.html ├── memory.html ├── renderer.js └── serialize-html.js ├── lib ├── render-queue.js ├── keypath.js ├── hashify.js ├── serialize-dom.js ├── elements-pool.js ├── adler32.js ├── renderer.js ├── serialize-html.js ├── modifier.js └── node.js ├── bower.json ├── component.json ├── bench └── index.js ├── package.json ├── LICENSE ├── x-package.json5 ├── demo └── playground.html ├── readme.md └── dist └── diff-renderer.js /history.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/renderer') 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | components 3 | .DS_Store 4 | npm-debug.log 5 | tmp 6 | *~ 7 | *.swp 8 | *.log 9 | *.pid 10 | *.swo 11 | coverage 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | node_modules/browserify/bin/cmd.js -e ./index.js -o dist/diff-renderer.js -s DiffRenderer 3 | xpkg . 4 | 5 | bench: 6 | node bench 7 | 8 | test: 9 | node_modules/.bin/qunit -c DiffRenderer:./index.js -t ./test/serialize-html.js --cov 10 | 11 | .PHONY: build bench test 12 | -------------------------------------------------------------------------------- /profile/html-parser.js: -------------------------------------------------------------------------------- 1 | var serializeHtml = require('../lib/serialize-html') 2 | var html = require('fs').readFileSync(__dirname + '/../bench/test.html', 'utf8') 3 | 4 | var i = 10000 5 | 6 | var now = Date.now() 7 | 8 | while (i > 0) { 9 | serializeHtml(html) 10 | i-- 11 | } 12 | 13 | console.log(Date.now() - now) 14 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | DiffRenderer 6 | 7 | 8 | 9 |
10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /lib/render-queue.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Any changed nodes land here to get considered for rendering. 5 | * 6 | * @type {Array} 7 | * @api private 8 | */ 9 | var queue = module.exports = [] 10 | 11 | /** 12 | * Add node to the queue. 13 | * 14 | * @param {Node} node 15 | * @api private 16 | */ 17 | queue.enqueue = function(node) { 18 | queue.push(node) 19 | } 20 | 21 | /** 22 | * Empty the queue. 23 | * 24 | * @param {Node} node 25 | * @api private 26 | */ 27 | queue.empty = function() { 28 | queue.splice(0) 29 | } 30 | 31 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "diff-renderer", 3 | "version": "0.1.1", 4 | "description": "Render diff to the dom.", 5 | "keywords": [ 6 | "diff", 7 | "renderer", 8 | "dom", 9 | "virtual dom", 10 | "react", 11 | "binding" 12 | ], 13 | "author": { 14 | "name": "Oleg Slobodskoi", 15 | "email": "oleg008@gmail.com" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git@github.com:kof/diff-renderer.git" 20 | }, 21 | "licenses": [ 22 | { 23 | "type": "MIT", 24 | "url": "http://www.opensource.org/licenses/mit-license.php" 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "diff-renderer", 3 | "version": "0.1.1", 4 | "description": "Render diff to the dom.", 5 | "keywords": [ 6 | "diff", 7 | "renderer", 8 | "dom", 9 | "virtual dom", 10 | "react", 11 | "binding" 12 | ], 13 | "author": { 14 | "name": "Oleg Slobodskoi", 15 | "email": "oleg008@gmail.com" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git@github.com:kof/diff-renderer.git" 20 | }, 21 | "licenses": [ 22 | { 23 | "type": "MIT", 24 | "url": "http://www.opensource.org/licenses/mit-license.php" 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /bench/index.js: -------------------------------------------------------------------------------- 1 | var html = require('fs').readFileSync(__dirname + '/test.html', 'utf8'), 2 | serializeHtml = require('../lib/serialize-html'), 3 | htmlParser = require('html-minifier/src/htmlparser'), 4 | htmltree = require('htmltree') 5 | 6 | var noop = function() {} 7 | 8 | exports.compare = {} 9 | 10 | exports.compare.diffRenderer = function() { 11 | serializeHtml(html) 12 | } 13 | 14 | exports.compare.htmlParser = function() { 15 | htmlParser.HTMLParser(html, {}) 16 | } 17 | 18 | exports.compare.htmltree = function() { 19 | htmltree(html, noop) 20 | } 21 | 22 | exports.stepsPerLap = 10 23 | 24 | require('bench').runMain() 25 | -------------------------------------------------------------------------------- /lib/keypath.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Find value in json obj using array or dot path notation. 5 | * 6 | * http://docs.mongodb.org/manual/core/document/#document-dot-notation 7 | * 8 | * {a: {b: {c: 3}}} 9 | * 'a.b.c' // 3 10 | * 11 | * {a: {b: {c: [1,2,3]}}} 12 | * 'a.b.c.1' // 2 13 | * 14 | * @param {Object|Array} obj 15 | * @param {String|Array} path 16 | * @return {Mixed} 17 | */ 18 | module.exports = function(obj, path) { 19 | var parts, i 20 | 21 | if (!obj || !path) return obj 22 | 23 | parts = typeof path == 'string' ? path.split('.') : path 24 | 25 | for (i = 0; i < parts.length; i++) { 26 | obj = obj[parts[i]] 27 | } 28 | 29 | return obj 30 | } 31 | -------------------------------------------------------------------------------- /test/memory.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "diff-renderer", 3 | "version": "0.1.1", 4 | "description": "Render diff to the dom.", 5 | "keywords": [ 6 | "diff", 7 | "renderer", 8 | "dom", 9 | "virtual dom", 10 | "react", 11 | "binding" 12 | ], 13 | "author": { 14 | "name": "Oleg Slobodskoi", 15 | "email": "oleg008@gmail.com" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git@github.com:kof/diff-renderer.git" 20 | }, 21 | "licenses": [ 22 | { 23 | "type": "MIT", 24 | "url": "http://www.opensource.org/licenses/mit-license.php" 25 | } 26 | ], 27 | "devDependencies": { 28 | "bench": "~0.3.5", 29 | "browserify": "~3.24.13", 30 | "html-minifier": "~0.5.5", 31 | "htmltree": "~0.0.4", 32 | "qunitjs": "~1.14.0", 33 | "qunit": "~0.7.2" 34 | }, 35 | "dependencies": { 36 | "docdiff": "0.0.1" 37 | } 38 | } -------------------------------------------------------------------------------- /lib/hashify.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var adler32 = require('./adler32') 4 | 5 | /** 6 | * Add hashes to every node. 7 | * Hash is calculated using node name, text, attributes and child nodes. 8 | * 9 | * @param {Object} node 10 | * @return {String} str which is used to generate a hash 11 | * @api private 12 | */ 13 | module.exports = function hashify(node) { 14 | var attr, i 15 | var str = '' 16 | var nodes 17 | 18 | if (!node) return str 19 | 20 | if (node.name) { 21 | str += node.name 22 | if (node.text) str += node.text 23 | 24 | for (attr in node.attributes) { 25 | str += attr + node.attributes[attr] 26 | } 27 | 28 | nodes = node.children 29 | // Its a collection. 30 | } else { 31 | nodes = node 32 | } 33 | 34 | for (i in nodes) { 35 | str += hashify(nodes[i]) 36 | } 37 | 38 | node.hash = adler32(str) 39 | 40 | return str 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2008-2014 Oleg Slobodskoi 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /x-package.json5: -------------------------------------------------------------------------------- 1 | { 2 | name: 'diff-renderer', 3 | version: '0.1.1', 4 | description: 'Render diff to the dom.', 5 | keywords: ['diff', 'renderer', 'dom', 'virtual dom', 'react', 'binding'], 6 | author: { 7 | name: 'Oleg Slobodskoi', 8 | email: 'oleg008@gmail.com' 9 | }, 10 | repository: { 11 | type: 'git', 12 | url: 'git@github.com:kof/diff-renderer.git' 13 | }, 14 | licenses: [ 15 | { 16 | type: 'MIT', 17 | url: 'http://www.opensource.org/licenses/mit-license.php' 18 | } 19 | ], 20 | overlay: { 21 | npm: { 22 | devDependencies: { 23 | bench: '~0.3.5', 24 | browserify: '~3.24.13', 25 | 'html-minifier': '~0.5.5', 26 | htmltree: '~0.0.4', 27 | qunitjs: '~1.14.0', 28 | qunit: '~0.7.2' 29 | }, 30 | dependencies: { 31 | docdiff: '0.0.1' 32 | } 33 | }, 34 | bower: true, 35 | component: true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/serialize-dom.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Walk through the dom and create a json snapshot. 5 | * 6 | * @param {Element} element 7 | * @return {Object} 8 | * @api private 9 | */ 10 | module.exports = function serialize(element) { 11 | var json = { 12 | name: element.nodeName.toLowerCase(), 13 | element: element 14 | } 15 | 16 | if (json.name == '#text') { 17 | json.text = element.textContent 18 | return json 19 | } 20 | 21 | var attr = element.attributes 22 | if (attr && attr.length) { 23 | json.attributes = {} 24 | var attrLength = attr.length 25 | for (var i = 0; i < attrLength; i++) { 26 | json.attributes[attr[i].name] = attr[i].value 27 | } 28 | } 29 | 30 | var childNodes = element.childNodes 31 | if (childNodes && childNodes.length) { 32 | json.children = {length: childNodes.length} 33 | var childNodesLength = childNodes.length 34 | for (var i = 0; i < childNodesLength; i++) { 35 | json.children[i] = serialize(childNodes[i]) 36 | } 37 | } 38 | 39 | return json 40 | } 41 | -------------------------------------------------------------------------------- /lib/elements-pool.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Dom nodes pool for dom reuse. 5 | * 6 | * @api private 7 | */ 8 | function ElementsPool() { 9 | this.store = {} 10 | } 11 | 12 | module.exports = ElementsPool 13 | 14 | /** 15 | * Get a dom element. Create if needed. 16 | * 17 | * @param {String} name 18 | * @return {Node} 19 | * @api private 20 | */ 21 | ElementsPool.prototype.allocate = function(name) { 22 | var nodes = this.store[name] 23 | return nodes && nodes.length ? nodes.shift() : this.createElement(name) 24 | } 25 | 26 | /** 27 | * Release a dom element. 28 | * 29 | * @param {Node} element 30 | * @api private 31 | */ 32 | ElementsPool.prototype.deallocate = function(element) { 33 | var name = element.nodeName.toLowerCase() 34 | if (this.store[name]) this.store[name].push(element) 35 | else this.store[name] = [element] 36 | } 37 | 38 | /** 39 | * Create dom element. 40 | * 41 | * @param {String} name - #text, div etc. 42 | * @return {Element} 43 | * @api private 44 | */ 45 | ElementsPool.prototype.createElement = function(name) { 46 | return name == '#text' ? document.createTextNode('') : document.createElement(name) 47 | } 48 | -------------------------------------------------------------------------------- /lib/adler32.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013-2014 Facebook, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | * @providesModule adler32 17 | */ 18 | 19 | /* jslint bitwise:true */ 20 | 21 | "use strict"; 22 | 23 | var MOD = 65521; 24 | 25 | // This is a clean-room implementation of adler32 designed for detecting 26 | // if markup is not what we expect it to be. It does not need to be 27 | // cryptographically strong, only reasonable good at detecting if markup 28 | // generated on the server is different than that on the client. 29 | function adler32(data) { 30 | var a = 1; 31 | var b = 0; 32 | for (var i = 0; i < data.length; i++) { 33 | a = (a + data.charCodeAt(i)) % MOD; 34 | b = (b + a) % MOD; 35 | } 36 | return a | (b << 16); 37 | } 38 | 39 | module.exports = adler32; 40 | -------------------------------------------------------------------------------- /demo/playground.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | DiffRenderer playground. 4 | 41 | 42 | 43 | 44 |
45 |

New html

46 | 47 | 48 |
49 |
50 |

Rendered view

51 | Render time: 52 |
53 |
54 | 55 | 60 | 61 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /lib/renderer.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var docdiff = require('docdiff') 4 | var keypath = require('./keypath') 5 | var Node = require('./node') 6 | var Modifier = require('./modifier') 7 | var serializeDom = require('./serialize-dom') 8 | var serializeHtml = require('./serialize-html') 9 | var renderQueue = require('./render-queue') 10 | var hashify = require('./hashify') 11 | 12 | /** 13 | * Renderer constructor. 14 | * 15 | * @param {Element} element dom node for serializing and updating. 16 | * @api public 17 | */ 18 | function Renderer(element) { 19 | if (!element) throw new TypeError('DOM element required') 20 | if (!(this instanceof Renderer)) return new Renderer(element) 21 | this.node = null 22 | this.modifier = null 23 | this.refresh(element) 24 | } 25 | 26 | module.exports = Renderer 27 | 28 | Renderer.serializeDom = serializeDom 29 | Renderer.serializeHtml = serializeHtml 30 | Renderer.keypath = keypath 31 | Renderer.docdiff = docdiff 32 | Renderer.hashify = hashify 33 | 34 | /** 35 | * Start checking render queue and render. 36 | * 37 | * @api public 38 | */ 39 | Renderer.start = function() { 40 | function check() { 41 | if (!Renderer.running) return 42 | Renderer.render() 43 | requestAnimationFrame(check) 44 | } 45 | 46 | Renderer.running = true 47 | requestAnimationFrame(check) 48 | } 49 | 50 | /** 51 | * Stop checking render queue and render. 52 | * 53 | * @api public 54 | */ 55 | Renderer.stop = function() { 56 | Renderer.running = false 57 | } 58 | 59 | /** 60 | * Render all queued nodes. 61 | * 62 | * @api public 63 | */ 64 | Renderer.render = function() { 65 | if (!renderQueue.length) return 66 | for (var i = 0; i < renderQueue.length; i++) { 67 | renderQueue[i].render() 68 | } 69 | renderQueue.empty() 70 | 71 | return this 72 | } 73 | 74 | /** 75 | * Create a snapshot from the dom. 76 | * 77 | * @param {Element} [element] 78 | * @return {Renderer} this 79 | * @api public 80 | */ 81 | Renderer.prototype.refresh = function(element) { 82 | if (!element && this.node) element = this.node.target 83 | if (this.node) this.node.unlink() 84 | var json = serializeDom(element) 85 | this.node = Node.create(json) 86 | this.modifier = new Modifier(this.node) 87 | 88 | return this 89 | } 90 | 91 | /** 92 | * Find changes and apply them to virtual nodes. 93 | * 94 | * @param {String} html 95 | * @return {Renderer} this 96 | * @api public 97 | */ 98 | Renderer.prototype.update = function(html) { 99 | var next = serializeHtml(html).children 100 | // Everything has been removed. 101 | if (!next) { 102 | this.node.removeChildren() 103 | return this 104 | } 105 | var current = this.node.toJSON().children || {} 106 | var diff = docdiff(current, next) 107 | this.modifier.apply(diff) 108 | 109 | return this 110 | } 111 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## Smart innerHTML for your views. 2 | 3 | DiffRenderer is a rendering component for your views, think of smart .innerHTML which renders only the changes. 4 | 5 | - high performance rendering 6 | - dramatically simplifies your view logic in non-trivial tasks 7 | - plays nicely with any library and web components 8 | 9 | Feel free to use any other project for events handling, templates, animations etc. 10 | 11 | ```javascript 12 | 13 | var DiffRenderer = require('diff-renderer') 14 | 15 | var el = document.getElementById('my-view') 16 | 17 | // Create renderer instance associated with some parent DOM element. 18 | var renderer = new DiffRenderer(el) 19 | 20 | // Put html you want to render. 21 | renderer.update('
My content
') 22 | 23 | // Start rendering loop once. 24 | DiffRenderer.start() 25 | ``` 26 | 27 | ## DOM is slow. 28 | 29 | If your view state has changed, you would normally manually go through changes and apply them to the dom by manipulating dom nodes or overwriting the view completely with .innerHTML. 30 | 31 | 1. Manipulating dom nodes in non trivial applications makes you view logic complicated. 32 | 2. Using .innerHTML causes performance and memory issues. 33 | 34 | ## Playground 35 | 36 | Visit [playground](//kof.github.com/diff-renderer/demo/playground.html) to see it in action. 37 | 38 | ## Use cases 39 | 40 | 1. Replacement for jQuery's dom manipulation methods and any direct dom manipulation. 41 | 1. Easy RESTful http API implementation: 42 | Client sends an object to the server, server validates it, client gets cleaned object back. With DiffRenderer we can apply the new one to the DOM without any checks. 43 | 1. Full bidirectional binding. For this you need to add the part for handling events and changing the state/data objects manually. 44 | 1. Real time data manipulation / rendering. 45 | 46 | ## How 47 | 48 | 1. It accepts a snapshot of your state in html or json format. You can use any template engine or none. 49 | 1. It calculates the difference to the cached state of the dom. 50 | 1. Intelligently renders the difference by only modifying/adding/removing nodes it has to. 51 | 1. It always reuses DOM elements. 52 | 53 | ## Gotchas 54 | 55 | 1. Don't attach listeners to the elements within renderer container. Do event delegation. 56 | 1. Don't change elements directly, use DiffRenderer. If you (or some lib) changed an element directly - refresh DiffRenderer. 57 | 58 | ## Bench 59 | 60 | - html parser - 200kb of html parsed in 15ms to json on my mb air. 61 | ``` 62 | npm i 63 | make bench 64 | ``` 65 | - jsperf of html parser vs. dom parsers http://jsperf.com/domparser-vs-jsparser 66 | - manual html parser memory test: open ./test/memory.html, observe your engines memory, click some times on buttons and see what happens 67 | 68 | ## Api 69 | 70 | - Use [requestAnimationFrame shim](https://github.com/kof/animation-frame) for older browsers. 71 | 72 | ### Get the api 73 | 74 | 1. Commonjs `var DiffRenderer = require('diff-renderer')` 75 | 2. From global 76 | - add script with browserified version from ./dist/diff-renderer.js 77 | - var DiffRenderer = window.DiffRenderer 78 | 79 | ### DiffRenderer(element) 80 | 81 | Create a renderer instance. Pass DOM element which you don't want to modify by DiffRenderer. Think of main view element f.e. like Backbone.View.prototype.el. 82 | 83 | ```javascript 84 | var el = document.getElementById('my-view') 85 | var renderer = new DiffRenderer(el) 86 | ``` 87 | 88 | ### DiffRenderer#update(html) 89 | 90 | Update renderer state with the new html. 91 | 92 | ```javascript 93 | renderer.update('
My new html
') 94 | ``` 95 | 96 | ### DiffRenderer#refresh() 97 | 98 | Serialize dom elements within renderer main element. You might need this if you modified the dom directly. 99 | 100 | ```javascript 101 | var el = document.getElementById('my-view') 102 | var renderer = new DiffRenderer(el) 103 | renderer.update('
My new html
') 104 | 105 | // Now me or some other library modifies the content (NOT RECOMMENDED) 106 | el.innerHTML = 'Test' 107 | 108 | // Now you want renderer let know that content has changed. 109 | renderer.refresh() 110 | ``` 111 | 112 | ### DiffRenderer.start() 113 | 114 | Start the renderer loop. Now on each animation frame renderer will render all queued changes. 115 | 116 | ```javascript 117 | DiffRenderer.start() 118 | 119 | var el = document.getElementById('my-view') 120 | var renderer = new Renderer(el) 121 | 122 | renderer.update('My fresh content will be rendered in the next animation frame.') 123 | ``` 124 | 125 | ### DiffRenderer.stop() 126 | 127 | Stop render loop. 128 | 129 | ### DiffRenderer.render() 130 | 131 | Render all queued changes from all renderer instances to the DOM. In the most cases you want to use `Renderer.start` instead. 132 | 133 | ```javascript 134 | var el1 = document.getElementById('my-view-1') 135 | var renderer1 = new Renderer(el1) 136 | var el2 = document.getElementById('my-view-2') 137 | var renderer2 = new Renderer(el2) 138 | 139 | // Now all virtual changes will be applied to the DOM. 140 | DiffRenderer.render() 141 | ``` 142 | 143 | ## Test 144 | - `make build` 145 | - Open the test suite ./test/index.html 146 | -------------------------------------------------------------------------------- /lib/serialize-html.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Simplified html parser. The fastest one written in javascript. 5 | * It is naive and requires valid html. 6 | * You might want to validate your html before to pass it here. 7 | * 8 | * @param {String} html 9 | * @param {Object} [parent] 10 | * @return {Object} 11 | * @api private 12 | */ 13 | module.exports = function serialize(str, parent) { 14 | if (!parent) parent = {name: 'root'} 15 | if (!str) return parent 16 | 17 | var i = 0 18 | var end = false 19 | var added = false 20 | var current 21 | var isWhite, isSlash, isOpen, isClose 22 | var inTag = false 23 | var inTagName = false 24 | var inAttrName = false 25 | var inAttrValue = false 26 | var inCloser = false 27 | var inClosing = false 28 | var isQuote, openQuote 29 | var attrName, attrValue 30 | var inText = false 31 | 32 | var json = { 33 | parent: parent, 34 | name: '' 35 | } 36 | 37 | while (!end) { 38 | current = str[i] 39 | isWhite = current == ' ' || current == '\t' || current == '\r' || current == '\n' 40 | isSlash = current == '/' 41 | isOpen = current == '<' 42 | isClose = current == '>' 43 | isQuote = current == "'" || current == '"' 44 | if (isSlash) inClosing = true 45 | if (isClose) inCloser = false 46 | 47 | if (current == null) { 48 | end = true 49 | } else { 50 | if (inTag) { 51 | if (inCloser) { 52 | delete json.name 53 | // Tag name 54 | } else if (inTagName || !json.name) { 55 | inTagName = true 56 | if ((json.name && isWhite) || isSlash) { 57 | inTagName = false 58 | if (!json.name) { 59 | inCloser = true 60 | if (parent.parent) parent = parent.parent 61 | } 62 | } else if (isClose) { 63 | serialize(str.substr(i + 1), inClosing || inCloser ? parent : json) 64 | return parent 65 | } else if (!isWhite) { 66 | json.name += current 67 | } 68 | // Attribute name 69 | } else if (inAttrName || !attrName) { 70 | inAttrName = true 71 | if (attrName == null) attrName = '' 72 | if (isSlash || 73 | (attrName && isWhite) || 74 | (attrName && current == '=')) { 75 | 76 | inAttrName = false 77 | if (attrName) { 78 | if (!json.attributes) json.attributes = {} 79 | json.attributes[attrName] = '' 80 | } 81 | } else if (isClose) { 82 | serialize(str.substr(i + 1), inClosing || inCloser ? parent : json) 83 | return parent 84 | } else if (!isWhite) { 85 | attrName += current 86 | } 87 | // Attribute value 88 | } else if (inAttrValue || attrName) { 89 | if (attrValue == null) attrValue = '' 90 | 91 | if (isQuote) { 92 | if (inAttrValue) { 93 | if (current == openQuote) { 94 | if (attrValue) json.attributes[attrName] = attrValue 95 | inAttrValue = false 96 | attrName = attrValue = null 97 | } else { 98 | attrValue += current 99 | } 100 | } else { 101 | inAttrValue = true 102 | openQuote = current 103 | } 104 | } else if (inAttrValue) { 105 | attrValue += current 106 | } 107 | } 108 | } else if (isOpen) { 109 | if (inText) { 110 | serialize(str.substr(i), parent) 111 | return parent 112 | } 113 | inTag = true 114 | } else if (isSlash && !inAttrValue) { 115 | end = true 116 | } else { 117 | inText = true 118 | inTag = false 119 | if (!json.name) json.name = '#text' 120 | if (json.text == null) json.text = '' 121 | json.text += current 122 | } 123 | 124 | if (json.name && !added) { 125 | if (!parent.children) parent.children = {length: 0} 126 | parent.children[parent.children.length] = json 127 | parent.children.length++ 128 | added = true 129 | } 130 | } 131 | 132 | if (isClose) inClosing = false 133 | 134 | ++i 135 | } 136 | 137 | return parent 138 | } 139 | -------------------------------------------------------------------------------- /lib/modifier.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var keypath = require('./keypath') 4 | var Node = require('./node') 5 | 6 | /** 7 | * Modifier applies changes to the node. 8 | * 9 | * @param {Node} node 10 | * @api private 11 | */ 12 | function Modifier(node) { 13 | this.node = node 14 | } 15 | 16 | module.exports = Modifier 17 | 18 | /** 19 | * Exclude properties from applying the diff. 20 | * 21 | * @type {Object} 22 | * @api private 23 | */ 24 | Modifier.EXCLUDE = { 25 | length: true, 26 | parent: true 27 | } 28 | 29 | /** 30 | * Apply the changes to the node. 31 | * 32 | * @param {Array} changes 33 | * @api private 34 | */ 35 | Modifier.prototype.apply = function(changes) { 36 | for (var i = 0; i < changes.length; i++) { 37 | var change = changes[i] 38 | var prop = change.path[change.path.length - 1] 39 | 40 | if (Modifier.EXCLUDE[prop]) continue 41 | 42 | var propIsNum = false 43 | if (!isNaN(prop)) { 44 | propIsNum = true 45 | prop = Number(prop) 46 | } 47 | 48 | var method = this[prop] 49 | if (!method) { 50 | if (propIsNum) method = this['children'] 51 | else method = this['attributes'] 52 | } 53 | method.call(this, change, prop) 54 | } 55 | } 56 | 57 | /** 58 | * Modify a text node. 59 | * 60 | * @param {Change} change 61 | * @param {String} prop 62 | * @api private 63 | */ 64 | Modifier.prototype.text = function(change, prop) { 65 | var path = change.path.slice(0, change.path.length - 1) 66 | var now = change.values.now 67 | var node = keypath(this.node.children, path) 68 | node.setText(now) 69 | } 70 | 71 | /** 72 | * Insert/remove child nodes. 73 | * 74 | * @param {Change} change 75 | * @param {String|Number} prop 76 | * @api private 77 | */ 78 | Modifier.prototype.children = function(change, prop) { 79 | var now = change.values.now 80 | var node 81 | var path 82 | 83 | if (change.change == 'add') { 84 | // Insert node at specific position. 85 | if (typeof prop == 'number') { 86 | // Find a path to the parent node. 87 | if (change.path.length > 1) { 88 | path = change.path.slice(0, change.path.length - 1) 89 | path.push(prop - 1) 90 | node = keypath(this.node.children, path) 91 | } else { 92 | node = this.node 93 | } 94 | node.insertAt(prop, Node.create(now, node)) 95 | // Append children. 96 | } else { 97 | path = change.path.slice(0, change.path.length - 1) 98 | node = keypath(this.node.children, path) 99 | for (var key in now) { 100 | if (!Modifier.EXCLUDE[key]) node.append(Node.create(now[key], node)) 101 | } 102 | } 103 | } else if (change.change == 'remove') { 104 | // Remove all children. 105 | if (prop == 'children') { 106 | path = change.path.slice(0, change.path.length - 1) 107 | node = keypath(this.node.children, path) 108 | node.removeChildren() 109 | } else { 110 | path = change.path 111 | node = keypath(this.node.children, path) 112 | if (node) node.parent.removeChild(node) 113 | } 114 | } 115 | } 116 | 117 | /** 118 | * Modify attributes. 119 | * 120 | * @param {Change} change 121 | * @param {String} prop 122 | * @api private 123 | */ 124 | Modifier.prototype.attributes = function(change, prop) { 125 | var now = change.values.now 126 | var path 127 | var node 128 | 129 | if (change.change == 'add') { 130 | if (prop == 'attributes') { 131 | path = change.path.slice(0, change.path.length - 1) 132 | node = keypath(this.node.children, path) 133 | node.setAttributes(now) 134 | } else { 135 | path = change.path.slice(0, change.path.length - 2) 136 | node = keypath(this.node.children, path) 137 | node.setAttribute(prop, now) 138 | } 139 | } else if (change.change == 'update') { 140 | path = change.path.slice(0, change.path.length - 2) 141 | node = keypath(this.node.children, path) 142 | node.setAttribute(prop, now) 143 | } else if (change.change == 'remove') { 144 | if (prop == 'attributes') { 145 | path = change.path.slice(0, change.path.length - 1) 146 | node = keypath(this.node.children, path) 147 | for (prop in change.values.original) { 148 | node.removeAttribute(prop) 149 | } 150 | } else { 151 | path = change.path.slice(0, change.path.length - 2) 152 | node = keypath(this.node.children, path) 153 | node.removeAttribute(prop) 154 | } 155 | } 156 | } 157 | 158 | /** 159 | * Change tag name. 160 | * 161 | * @param {Change} change 162 | * @param {String} prop 163 | * @api private 164 | */ 165 | Modifier.prototype.name = function(change, prop) { 166 | var path = change.path.slice(0, change.path.length - 1) 167 | var node = keypath(this.node.children, path) 168 | var now = change.values.now 169 | node.setName(now) 170 | } 171 | -------------------------------------------------------------------------------- /test/renderer.js: -------------------------------------------------------------------------------- 1 | function getView() { 2 | var element = document.createElement('div') 3 | var renderer = new DiffRenderer(element) 4 | return { 5 | render: function(html) { 6 | renderer.update(html) 7 | DiffRenderer.render() 8 | }, 9 | element: element 10 | } 11 | } 12 | 13 | module('DiffRenderer') 14 | 15 | test('render a tag', function() { 16 | var view = getView() 17 | view.render('') 18 | equal(view.element.innerHTML, '', 'self closing') 19 | view.render('') 20 | equal(view.element.innerHTML, '', 'not self closing') 21 | }) 22 | 23 | test('render a text node', function() { 24 | var view = getView() 25 | view.render('abc') 26 | equal(view.element.innerHTML, 'abc', 'without spaces') 27 | view.render(' abc ') 28 | equal(view.element.innerHTML, ' abc ', 'with spaces') 29 | }) 30 | 31 | test('add an attribute', function() { 32 | var view = getView() 33 | view.render('') 34 | view.render('') 35 | equal(view.element.innerHTML, '', 'add class') 36 | view.render('') 37 | equal(view.element.innerHTML, '', 'add id') 38 | view.render('') 39 | equal(view.element.innerHTML, '', 'add empty href') 40 | view.render('') 41 | equal(view.element.innerHTML, '', 'add disabled') 42 | }) 43 | 44 | test('add an attribute', function() { 45 | var view = getView() 46 | view.render('') 47 | view.render('') 48 | equal(view.element.innerHTML, '', 'add class') 49 | view.render('') 50 | equal(view.element.innerHTML, '', 'add id') 51 | view.render('') 52 | equal(view.element.innerHTML, '', 'add empty href') 53 | view.render('') 54 | equal(view.element.innerHTML, '', 'add disabled') 55 | }) 56 | 57 | test('change an attribute', function() { 58 | var view = getView() 59 | view.render('') 60 | view.render('') 61 | equal(view.element.innerHTML, '') 62 | }) 63 | 64 | test('change an attributes', function() { 65 | var view = getView() 66 | view.render('') 67 | view.render('') 68 | equal(view.element.innerHTML, '') 69 | }) 70 | 71 | test('remove an attribute', function() { 72 | var view = getView() 73 | view.render('') 74 | view.render('') 75 | equal(view.element.innerHTML, '') 76 | }) 77 | 78 | test('remove all attributes', function() { 79 | var view = getView() 80 | view.render('') 81 | view.render('') 82 | equal(view.element.innerHTML, '') 83 | }) 84 | 85 | test('remove one of attributes', function() { 86 | var view = getView() 87 | view.render('') 88 | view.render('') 89 | equal(view.element.innerHTML, '') 90 | }) 91 | 92 | test('change text node text', function() { 93 | var view = getView() 94 | view.render('abc') 95 | view.render('a') 96 | equal(view.element.innerHTML, 'a') 97 | }) 98 | 99 | test('change text node text within a tag', function() { 100 | var view = getView() 101 | view.render('aaa') 102 | view.render('a') 103 | equal(view.element.innerHTML, 'a') 104 | }) 105 | 106 | test('replace tag by another tag', function() { 107 | var view = getView() 108 | view.render('') 109 | view.render('') 110 | equal(view.element.innerHTML, '') 111 | }) 112 | 113 | test('replace multiple tags by 1 other tag', function() { 114 | var view = getView() 115 | view.render('') 116 | view.render('') 117 | equal(view.element.innerHTML, '') 118 | }) 119 | 120 | test('replace multiple tags by 1 text node', function() { 121 | var view = getView() 122 | view.render('') 123 | view.render('aaa') 124 | equal(view.element.innerHTML, 'aaa') 125 | }) 126 | 127 | test('replace multiple tags by multiple tags', function() { 128 | var view = getView() 129 | view.render('') 130 | view.render('') 131 | equal(view.element.innerHTML, '') 132 | }) 133 | 134 | test('replace first tag by another one', function() { 135 | var view = getView() 136 | view.render('') 137 | view.render('') 138 | equal(view.element.innerHTML, '') 139 | }) 140 | 141 | test('replace first tag by text node', function() { 142 | var view = getView() 143 | view.render('') 144 | view.render('a') 145 | equal(view.element.innerHTML, 'a') 146 | }) 147 | 148 | test('replace last tag by another one', function() { 149 | var view = getView() 150 | view.render('') 151 | view.render('') 152 | equal(view.element.innerHTML, '') 153 | }) 154 | 155 | test('replace middle tag by another one', function() { 156 | var view = getView() 157 | view.render('') 158 | view.render('') 159 | equal(view.element.innerHTML, '') 160 | }) 161 | 162 | test('append a tag', function() { 163 | var view = getView() 164 | view.render('') 165 | view.render('') 166 | equal(view.element.innerHTML, '') 167 | }) 168 | 169 | test('append a text node', function() { 170 | var view = getView() 171 | view.render('') 172 | view.render('b') 173 | equal(view.element.innerHTML, 'b') 174 | }) 175 | 176 | test('prepend a tag', function() { 177 | var view = getView() 178 | view.render('') 179 | view.render('') 180 | equal(view.element.innerHTML, '') 181 | }) 182 | 183 | test('prepend multiple tags', function() { 184 | var view = getView() 185 | view.render('') 186 | view.render('') 187 | equal(view.element.innerHTML, '') 188 | }) 189 | 190 | test('prepend a text node', function() { 191 | var view = getView() 192 | view.render('') 193 | view.render('b') 194 | equal(view.element.innerHTML, 'b') 195 | }) 196 | 197 | test('insert a tag after', function() { 198 | var view = getView() 199 | view.render('') 200 | view.render('') 201 | equal(view.element.innerHTML, '') 202 | }) 203 | 204 | test('insert multiple tags after', function() { 205 | var view = getView() 206 | view.render('') 207 | view.render('') 208 | equal(view.element.innerHTML, '') 209 | }) 210 | 211 | test('insert multiple tags in the middle', function() { 212 | var view = getView() 213 | view.render('') 214 | view.render('') 215 | equal(view.element.innerHTML, '') 216 | }) 217 | 218 | test('migrate children', function() { 219 | var view = getView() 220 | view.render('a') 221 | view.render('a') 222 | equal(view.element.innerHTML, 'a') 223 | }) 224 | 225 | test('remove text node within a tag', function() { 226 | var view = getView() 227 | view.render('abc') 228 | view.render('') 229 | equal(view.element.innerHTML, '') 230 | }) 231 | 232 | test('remove text node', function() { 233 | var view = getView() 234 | view.render('abc') 235 | view.render('') 236 | equal(view.element.innerHTML, '') 237 | }) 238 | 239 | test('remove first tag', function() { 240 | var view = getView() 241 | view.render('') 242 | view.render('') 243 | equal(view.element.innerHTML, '') 244 | }) 245 | 246 | test('remove middle tag', function() { 247 | var view = getView() 248 | view.render('') 249 | view.render('') 250 | equal(view.element.innerHTML, '') 251 | }) 252 | 253 | test('remove last tag', function() { 254 | var view = getView() 255 | view.render('') 256 | view.render('') 257 | equal(view.element.innerHTML, '') 258 | }) 259 | 260 | test('remove last tag', function() { 261 | var view = getView() 262 | view.render('') 263 | view.render('') 264 | equal(view.element.innerHTML, '') 265 | }) 266 | 267 | test('allow nesting of renderers', function() { 268 | var element1 = document.createElement('div') 269 | element1.className = '1' 270 | var element2 = document.createElement('div') 271 | element2.className = '2' 272 | var element3 = document.createElement('div') 273 | element3.className = '3' 274 | element1.appendChild(element2) 275 | element2.appendChild(element3) 276 | 277 | var renderer1 = new DiffRenderer(element1) 278 | var renderer2 = new DiffRenderer(element2) 279 | 280 | ok(renderer1.node.children[0] === renderer2.node) 281 | ok(renderer1.node.children[0].children[0] === renderer2.node.children[0]) 282 | }) 283 | -------------------------------------------------------------------------------- /test/serialize-html.js: -------------------------------------------------------------------------------- 1 | var serializeHtml = DiffRenderer.serializeHtml 2 | var hashify = DiffRenderer.hashify 3 | 4 | QUnit.module('serialize-html') 5 | 6 | test('text node 0', function() { 7 | var doc = serializeHtml('a') 8 | var node = doc.children[0] 9 | hashify(node) 10 | equal(doc.name, 'root', 'root name') 11 | equal(node.name, '#text', 'node name') 12 | equal(node.text, 'a', 'node text') 13 | equal(node.hash, 123798090, 'node hash') 14 | }) 15 | 16 | test('text node 1', function() { 17 | var node = serializeHtml(' abc ').children[0] 18 | hashify(node) 19 | equal(node.name, '#text', 'node name') 20 | equal(node.text, ' abc ', 'node text') 21 | equal(node.hash, 315884367, 'node hash') 22 | }) 23 | 24 | test('empty node 0', function() { 25 | var node = serializeHtml('').children[0] 26 | hashify(node) 27 | equal(node.name, 'a', 'node name') 28 | equal(node.hash, 6422626, 'node hash') 29 | }) 30 | 31 | test('empty node 1', function() { 32 | var node = serializeHtml('< a/>').children[0] 33 | hashify(node) 34 | equal(node.name, 'a', 'node name') 35 | equal(node.hash, 6422626, 'node hash') 36 | }) 37 | 38 | test('empty node 2', function() { 39 | var node = serializeHtml('< a / >').children[0] 40 | hashify(node) 41 | equal(node.name, 'a', 'node name') 42 | equal(node.hash, 6422626, 'node hash') 43 | }) 44 | 45 | test('empty node 3', function() { 46 | var node = serializeHtml('<\na\n/\n>').children[0] 47 | hashify(node) 48 | equal(node.name, 'a', 'node name') 49 | equal(node.hash, 6422626, 'node hash') 50 | }) 51 | 52 | test('closing tag', function() { 53 | deepEqual(serializeHtml(''), {name: 'root'}) 54 | deepEqual(serializeHtml('< / a\n >'), {name: 'root'}) 55 | deepEqual(serializeHtml('<\n/\n a\n >'), {name: 'root'}) 56 | }) 57 | 58 | test('attributes 0', function() { 59 | var node = serializeHtml('').children[0] 60 | hashify(node) 61 | equal(node.name, 'a', 'node name') 62 | deepEqual(node.attributes, {id: ''}, 'attributes') 63 | equal(node.hash, 39584047, 'node hash') 64 | }) 65 | 66 | test('attributes 1', function() { 67 | var node = serializeHtml('').children[0] 68 | hashify(node) 69 | equal(node.name, 'a', 'node name') 70 | deepEqual(node.attributes, {id: ''}, 'attributes') 71 | equal(node.hash, 39584047, 'node hash') 72 | }) 73 | 74 | test('attributes 2', function() { 75 | var node = serializeHtml('').children[0] 76 | hashify(node) 77 | equal(node.name, 'a', 'node name') 78 | deepEqual(node.attributes, {id: ''}, 'attribtues') 79 | equal(node.hash, 39584047, 'node hash') 80 | }) 81 | 82 | test('attributes 3', function() { 83 | var node = serializeHtml('').children[0] 84 | hashify(node) 85 | equal(node.name, 'a', 'node name') 86 | deepEqual(node.attributes, {id: 'a'}, 'attributes') 87 | equal(node.hash, 65798544, 'node hash') 88 | }) 89 | 90 | test('attributes 4', function() { 91 | var node = serializeHtml('').children[0] 92 | hashify(node) 93 | equal(node.name, 'a', 'node name') 94 | deepEqual(node.attributes, {id: 'a'}, 'attributes') 95 | equal(node.hash, 65798544, 'node hash') 96 | }) 97 | 98 | test('attributes 5', function() { 99 | var node = serializeHtml('').children[0] 100 | hashify(node) 101 | equal(node.name, 'a', 'node name') 102 | deepEqual(node.attributes, {id: "a'"}, 'attributes') 103 | equal(node.hash, 94568887, 'node hash') 104 | }) 105 | 106 | test('attributes 6', function() { 107 | var node = serializeHtml('').children[0] 108 | hashify(node) 109 | equal(node.name, 'a', 'node name') 110 | deepEqual(node.attributes, {id: "a='b'"}, 'attributes') 111 | equal(node.hash, 209715837, 'node hash') 112 | }) 113 | 114 | test('attributes 7', function() { 115 | var node = serializeHtml('').children[0] 116 | hashify(node) 117 | equal(node.name, 'a', 'node name') 118 | deepEqual(node.attributes, {id: 'a', class: '\nb '}, 'attributes') 119 | equal(node.hash, 499844146, 'node hash') 120 | }) 121 | 122 | test('attributes 8', function() { 123 | var node = serializeHtml('').children[0] 124 | hashify(node) 125 | equal(node.name, 'a', 'node name') 126 | deepEqual(node.attributes, {attr1: 'first', attr2: 'second'}, 'attributes') 127 | equal(node.hash, 1708460255, 'node hash') 128 | }) 129 | 130 | test('attributes 9', function() { 131 | var node = serializeHtml('').children[0] 132 | hashify(node) 133 | equal(node.name, 'a', 'node name') 134 | deepEqual(node.attributes, {attr: '

'}, 'attributes') 135 | equal(node.hash, 239928071, 'node hash') 136 | }) 137 | 138 | test('children 0', function() { 139 | var node = serializeHtml('a').children[0] 140 | hashify(node) 141 | equal(node.name, 'a', 'node name') 142 | equal(node.hash, 168362667, 'node hash') 143 | equal(node.children[0].name, '#text', 'child name') 144 | equal(node.children[0].text, 'a', 'child text') 145 | equal(node.children.length, 1, 'children length') 146 | }) 147 | 148 | test('children 1', function() { 149 | var node = serializeHtml('\n').children[0] 150 | hashify(node) 151 | equal(node.name, 'a', 'node name') 152 | equal(node.hash, 162660948, 'node hash') 153 | equal(node.children[0].name, '#text', 'child 0 name') 154 | equal(node.children[0].text, '\n', 'child 0 text') 155 | equal(node.children[0].hash, 118096371, 'child 0 hash') 156 | equal(node.children.length, 1, 'children length') 157 | }) 158 | 159 | test('children 2', function() { 160 | var node = serializeHtml(' a \n b ').children[0] 161 | hashify(node) 162 | equal(node.name, 'a', 'node name') 163 | equal(node.hash, 479593367, 'node hash') 164 | equal(node.children[0].name, '#text', 'child 0 name') 165 | equal(node.children[0].text, ' a \n b ', 'child 0 text') 166 | equal(node.children[0].hash, 396886838, 'child 0 hash') 167 | equal(node.children.length, 1, 'children length') 168 | }) 169 | 170 | test('children 3', function() { 171 | var node = serializeHtml('\n').children[0] 172 | hashify(node) 173 | equal(node.name, 'a', 'node name') 174 | equal(node.hash, 208143030, 'node hash') 175 | equal(node.children[0].name, '#text', 'child 0 name') 176 | equal(node.children[0].text, '\n', 'child 0 text') 177 | equal(node.children[0].hash, 118096371, 'child 0 hash') 178 | equal(node.children[1].name, 'b', 'child 1 name') 179 | equal(node.children[1].hash, 6488163, 'child 1 hash') 180 | equal(node.children.length, 2, 'children length') 181 | }) 182 | 183 | test('children 4', function() { 184 | var node = serializeHtml('\n\n\n').children[0] 185 | hashify(node) 186 | equal(node.name, 'a', 'node name') 187 | equal(node.hash, 762774805, 'node hash') 188 | equal(node.children[0].name, '#text', 'child 0 name') 189 | equal(node.children[0].text, '\n', 'child 0 text') 190 | equal(node.children[0].hash, 118096371, 'child 0 hash') 191 | equal(node.children[1].name, 'b', 'child 1 name') 192 | equal(node.children[1].hash, 6488163, 'child 1 hash') 193 | equal(node.children[2].name, '#text', 'child 2 name') 194 | equal(node.children[2].text, '\n\n', 'child 2 text') 195 | equal(node.children[2].hash, 151454205, 'child 2 hash') 196 | equal(node.children[3].name, 'c', 'child 3 name') 197 | equal(node.children[3].hash, 6553700, 'child 3 hash') 198 | equal(node.children.length, 4, 'children length') 199 | }) 200 | 201 | test('children 5', function() { 202 | var node = serializeHtml('').children[0] 203 | hashify(node) 204 | equal(node.name, 'a', 'node name') 205 | equal(node.hash, 19202243, 'node hash') 206 | equal(node.children[0].name, 'a', 'child 0 name') 207 | equal(node.children[0].hash, 6422626, 'child 0 hash') 208 | equal(node.children.length, 1, 'children length') 209 | }) 210 | 211 | test('children 6', function() { 212 | var node = serializeHtml('').children[0] 213 | hashify(node) 214 | equal(node.name, 'a', 'node name') 215 | equal(node.hash, 19267780, 'node hash') 216 | equal(node.children[0].name, 'b', 'child 0 name') 217 | equal(node.children[0].hash, 6488163, 'child 0 hash') 218 | equal(node.children.length, 1, 'children length') 219 | }) 220 | 221 | test('children 7', function() { 222 | var node = serializeHtml('').children[0] 223 | hashify(node) 224 | equal(node.name, 'a', 'node name') 225 | equal(node.hash, 240255805, 'node hash') 226 | equal(node.children[0].name, 'b', 'child 0 name') 227 | equal(node.children[0].hash, 189334236, 'child 0 hash') 228 | deepEqual(node.children[0].attributes, {class: 'c'}, 'child 0 attributes') 229 | equal(node.children.length, 1, 'children length') 230 | }) 231 | 232 | test('collection 0', function() { 233 | var nodes = serializeHtml('').children 234 | hashify(nodes) 235 | equal(nodes[0].name, 'a', 'node 0 name') 236 | equal(nodes[0].hash, 6422626, 'node 0 hash') 237 | equal(nodes[0].parent.name, 'root' , 'node 0 parent') 238 | equal(nodes[1].name, 'b', 'node 1 name') 239 | equal(nodes[1].hash, 6488163, 'node 1 hash') 240 | equal(nodes[1].parent.name, 'root' , 'node 1 parent') 241 | equal(nodes[2].name, 'c', 'node 2 name') 242 | equal(nodes[2].hash, 6553700, 'node 2 hash') 243 | equal(nodes[2].parent.name, 'root' , 'node 2 parent') 244 | equal(nodes.length, 3, 'nodes length') 245 | }) 246 | 247 | test('collection 1', function() { 248 | var nodes = serializeHtml('').children 249 | hashify(nodes) 250 | equal(nodes[0].name, 'a', 'node 0 name') 251 | equal(nodes[0].hash, 6422626, 'node 0 hash') 252 | equal(nodes[0].parent.name, 'root' , 'node 0 parent') 253 | equal(nodes[1].name, 'b', 'node 1 name') 254 | equal(nodes[1].hash, 6488163, 'node 1 hash') 255 | equal(nodes[1].parent.name, 'root' , 'node 1 parent') 256 | equal(nodes[2].name, 'c', 'node 2 name') 257 | equal(nodes[2].hash, 6553700, 'node 2 hash') 258 | equal(nodes[2].parent.name, 'root' , 'node 2 parent') 259 | equal(nodes.length, 3, 'nodes length') 260 | }) 261 | 262 | test('collection 2', function() { 263 | var node = serializeHtml('').children[0] 264 | hashify(node) 265 | equal(node.name, 'a', 'node name') 266 | equal(node.hash, 38600999, 'node hash') 267 | equal(node.children[0].name, 'b', 'child 0 name') 268 | equal(node.children[0].hash, 6488163, 'child 0 hash') 269 | equal(node.children[1].name, 'c', 'child 1 name') 270 | equal(node.children[1].hash, 6553700, 'child 1 hash') 271 | equal(node.children.length, 2, 'children length') 272 | }) 273 | 274 | test('collection 3', function() { 275 | var node = serializeHtml('').children[0] 276 | hashify(node) 277 | equal(node.name, 'a', 'node name') 278 | equal(node.hash, 38600999, 'node hash') 279 | equal(node.children[0].name, 'b', 'child 0 name') 280 | equal(node.children[0].hash, 6488163, 'child 0 hash') 281 | equal(node.children[1].name, 'c', 'child 1 name') 282 | equal(node.children[1].hash, 6553700, 'child 1 hash') 283 | equal(node.children.length, 2, 'children length') 284 | }) 285 | 286 | 287 | -------------------------------------------------------------------------------- /lib/node.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var ElementsPool = require('./elements-pool') 4 | var queue = require('./render-queue') 5 | 6 | // Global elements pool for all nodes and all renderer instances. 7 | var pool = new ElementsPool() 8 | 9 | var counter = 0 10 | 11 | var nodesMap = {} 12 | 13 | var ID_NAMESPACE = '__diffRendererId__' 14 | 15 | /** 16 | * Abstract node which can be rendered to a dom node. 17 | * 18 | * @param {Object} options 19 | * @param {String} options.name tag name 20 | * @param {String} [options.text] text for the text node 21 | * @param {Object} [options.attributes] key value hash of name/values 22 | * @param {Element} [options.element] if element is passed, node is already rendered 23 | * @param {Object} [options.children] NodeList like collection 24 | * @param {Node} [parent] 25 | * @api private 26 | */ 27 | function Node(options, parent) { 28 | this.id = counter++ 29 | this.name = options.name 30 | this.parent = parent 31 | if (options.text) this.text = options.text 32 | if (options.attributes) this.attributes = options.attributes 33 | if (options.element) { 34 | this.setTarget(options.element) 35 | // Not dirty if element passed. 36 | } else { 37 | this.dirty('name', true) 38 | if (this.text) this.dirty('text', true) 39 | if (this.attributes) this.dirty('attributes', this.attributes) 40 | } 41 | 42 | if (options.children) { 43 | this.children = [] 44 | for (var i in options.children) { 45 | if (i != 'length') { 46 | this.children[i] = Node.create(options.children[i], this) 47 | if (this.children[i].dirty()) this.dirty('children', true) 48 | } 49 | } 50 | } 51 | 52 | nodesMap[this.id] = this 53 | } 54 | 55 | module.exports = Node 56 | 57 | /** 58 | * Create Node instance, check if passed element has already a Node. 59 | * 60 | * @see {Node} 61 | * @api private 62 | * @return {Node} 63 | */ 64 | Node.create = function(options, parent) { 65 | if (options.element && options.element[ID_NAMESPACE]) { 66 | return nodesMap[options.element[ID_NAMESPACE]] 67 | } 68 | return new Node(options, parent) 69 | } 70 | 71 | /** 72 | * Serialize instance to json data. 73 | * 74 | * @return {Object} 75 | * @api private 76 | */ 77 | Node.prototype.toJSON = function() { 78 | var json = {name: this.name} 79 | if (this.text) json.text = this.text 80 | if (this.attributes) json.attributes = this.attributes 81 | if (this.children) { 82 | json.children = {length: this.children.length} 83 | for (var i = 0; i < this.children.length; i++) { 84 | json.children[i] = this.children[i].toJSON() 85 | } 86 | } 87 | 88 | return json 89 | } 90 | 91 | /** 92 | * Allocate, setup current target, insert children to the dom. 93 | * 94 | * @api private 95 | */ 96 | Node.prototype.render = function() { 97 | if (!this._dirty) return 98 | if (this.dirty('name')) { 99 | if (this.target) this.migrate() 100 | else this.setTarget(pool.allocate(this.name)) 101 | this.dirty('name', null) 102 | } 103 | 104 | // Handle children 105 | if (this.dirty('children') && this.children) { 106 | var newChildren = [] 107 | for (var i = 0; i < this.children.length; i++) { 108 | var child = this.children[i] 109 | // Children can be dirty for removal or for insertions only. 110 | // All other changes are handled by the child.render. 111 | if (child.dirty()) { 112 | if (child.dirty('remove')) { 113 | this.removeChildAt(i) 114 | child.dirty('remove', null) 115 | delete nodesMap[child.id] 116 | // Handle insert. 117 | } else { 118 | var next = this.children[i + 1] 119 | child.render() 120 | this.target.insertBefore(child.target, next && next.target) 121 | newChildren.push(child) 122 | child.dirty('insert', null) 123 | child.dirty('name', null) 124 | } 125 | } else { 126 | newChildren.push(child) 127 | } 128 | } 129 | // We migrate children to the new array because some of them might be removed 130 | // and if we splice them directly, we will remove wrong elements. 131 | if (newChildren) this.children = newChildren 132 | this.dirty('children', null) 133 | } 134 | 135 | // Handle textContent. 136 | if (this.dirty('text') && this.text) { 137 | this.target.textContent = this.text 138 | this.dirty('text', null) 139 | } 140 | 141 | // Handle attribtues. 142 | if (this.dirty('attributes')) { 143 | var attributes = this.dirty('attributes') 144 | for (var attrName in attributes) { 145 | var value = attributes[attrName] 146 | if (value == null) { 147 | delete this.attributes[attrName] 148 | if (this.name != '#text') this.target.removeAttribute(attrName) 149 | } else { 150 | if (!this.attributes) this.attributes = {} 151 | this.attributes[attrName] = value 152 | this.target.setAttribute(attrName, value) 153 | } 154 | } 155 | this.dirty('attributes', null) 156 | } 157 | } 158 | 159 | /** 160 | * Remove child DOM element at passed position without removing from children array. 161 | * 162 | * @param {Number} position 163 | * @api private 164 | */ 165 | Node.prototype.removeChildAt = function(position) { 166 | var child = this.children[position] 167 | child.detach() 168 | child.cleanup() 169 | if (child.children) { 170 | for (var i = 0; i < child.children.length; i++) { 171 | child.removeChildAt(i) 172 | } 173 | } 174 | pool.deallocate(child.target) 175 | child.unlink() 176 | } 177 | 178 | /** 179 | * Migrate target DOM element and its children to a new DOM element. 180 | * F.e. because tagName changed. 181 | * 182 | * @api private 183 | */ 184 | Node.prototype.migrate = function() { 185 | this.detach() 186 | this.cleanup() 187 | var oldTarget = this.target 188 | this.setTarget(pool.allocate(this.name)) 189 | 190 | // Migrate children. 191 | if (this.name == '#text') { 192 | this.children = null 193 | } else { 194 | this.text = null 195 | while (oldTarget.hasChildNodes()) { 196 | this.target.appendChild(oldTarget.removeChild(oldTarget.firstChild)) 197 | } 198 | } 199 | pool.deallocate(oldTarget) 200 | this.dirty('insert', true) 201 | } 202 | 203 | /** 204 | * Remove target DOM element from the render tree. 205 | * 206 | * @api private 207 | */ 208 | Node.prototype.detach = function() { 209 | var parentNode = this.target.parentNode 210 | if (parentNode) parentNode.removeChild(this.target) 211 | } 212 | 213 | /** 214 | * Clean up everything changed on the target DOM element. 215 | * 216 | * @api private 217 | */ 218 | Node.prototype.cleanup = function() { 219 | if (this.attributes) { 220 | for (var attrName in this.attributes) this.target.removeAttribute(attrName) 221 | } 222 | if (this.text) this.target.textContent = '' 223 | delete this.target[ID_NAMESPACE] 224 | } 225 | 226 | /** 227 | * Set all child nodes set dirty for removal. 228 | * 229 | * @api private 230 | */ 231 | Node.prototype.removeChildren = function() { 232 | if (!this.children) return 233 | 234 | for (var i = 0; i < this.children.length; i++) { 235 | this.children[i].dirty('remove', true) 236 | } 237 | 238 | this.dirty('children', true) 239 | } 240 | 241 | /** 242 | * Set child element as dirty for removal. 243 | * 244 | * @param {Node} node 245 | * @api private 246 | */ 247 | Node.prototype.removeChild = function(child) { 248 | child.dirty('remove', true) 249 | this.dirty('children', true) 250 | } 251 | 252 | /** 253 | * Clean up all references for current and child nodes. 254 | * 255 | * @api private 256 | */ 257 | Node.prototype.unlink = function() { 258 | if (this.children) { 259 | for (var i = 0; i < this.children.length; i++) { 260 | this.children[i].unlink() 261 | } 262 | } 263 | delete this.id 264 | delete this.name 265 | delete this.text 266 | delete this.attributes 267 | delete this.parent 268 | delete this.children 269 | delete this.target 270 | delete this._dirty 271 | } 272 | 273 | /** 274 | * Insert a node at specified position. 275 | * 276 | * @param {Number} position 277 | * @param {Node} node 278 | * @api private 279 | */ 280 | Node.prototype.insertAt = function(position, node) { 281 | this.dirty('children', true) 282 | node.dirty('insert', true) 283 | if (!this.children) this.children = [] 284 | this.children.splice(position, 0, node) 285 | } 286 | 287 | /** 288 | * Insert a node at the end. 289 | * 290 | * @param {Node} node 291 | * @api private 292 | */ 293 | Node.prototype.append = function(node) { 294 | var position = this.children ? this.children.length : 0 295 | this.insertAt(position, node) 296 | } 297 | 298 | /** 299 | * Set nodes attributes. 300 | * 301 | * @param {Object} attribtues 302 | * @api private 303 | */ 304 | Node.prototype.setAttributes = function(attributes) { 305 | for (var name in attributes) { 306 | this.setAttribute(name, attributes[name]) 307 | } 308 | } 309 | 310 | /** 311 | * Set nodes attribute. 312 | * 313 | * @param {String} name 314 | * @param {String|Boolean|Null} value, use null to remove 315 | * @api private 316 | */ 317 | Node.prototype.setAttribute = function(name, value) { 318 | if (this.attributes && this.attributes[name] == value) return 319 | var attributes = this.dirty('attributes') || {} 320 | attributes[name] = value 321 | this.dirty('attributes', attributes) 322 | } 323 | 324 | /** 325 | * Remove nodes attribute. 326 | * @param {String} name 327 | * @api private 328 | */ 329 | Node.prototype.removeAttribute = function(name) { 330 | if (this.attributes && this.attributes[name] != null) this.setAttribute(name, null) 331 | } 332 | 333 | /** 334 | * Set text content. 335 | * 336 | * @param {String} content 337 | * @api private 338 | */ 339 | Node.prototype.setText = function(text) { 340 | if (this.name != '#text' || text == this.text) return 341 | this.dirty('text', true) 342 | this.text = text 343 | } 344 | 345 | /** 346 | * Element name can't be set, we need to swap out the element. 347 | * 348 | * @param {String} name 349 | * @api private 350 | */ 351 | Node.prototype.setName = function(name) { 352 | if (name == this.name) return 353 | this.dirty('name', true) 354 | this.parent.dirty('children', true) 355 | this.name = name 356 | } 357 | 358 | /** 359 | * Set target element. 360 | * 361 | * @param {Element} element 362 | * @api private 363 | */ 364 | Node.prototype.setTarget = function(element) { 365 | element[ID_NAMESPACE] = this.id 366 | this.target = element 367 | } 368 | 369 | /** 370 | * Get/set/unset a dirty flag, add to render queue. 371 | * 372 | * @param {String} name 373 | * @param {Mixed} [value] 374 | * @api private 375 | */ 376 | Node.prototype.dirty = function(name, value) { 377 | // Get flag. 378 | if (value === undefined) { 379 | return this._dirty && name ? this._dirty[name] : this._dirty 380 | } 381 | 382 | // Unset dirty flag. 383 | if (value === null) { 384 | if (this._dirty) { 385 | delete this._dirty[name] 386 | // If if its not empty object - exist. 387 | for (name in this._dirty) return 388 | // For quick check. 389 | delete this._dirty 390 | } 391 | // Set dirty flag. 392 | } else { 393 | if (!this._dirty) { 394 | this._dirty = {} 395 | // Only enqueue if its the first flag. 396 | queue.enqueue(this) 397 | } 398 | this._dirty[name] = value 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /dist/diff-renderer.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.DiffRenderer=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 1) { 257 | path = change.path.slice(0, change.path.length - 1) 258 | path.push(prop - 1) 259 | node = keypath(this.node.children, path) 260 | } else { 261 | node = this.node 262 | } 263 | node.insertAt(prop, Node.create(now, node)) 264 | // Append children. 265 | } else { 266 | path = change.path.slice(0, change.path.length - 1) 267 | node = keypath(this.node.children, path) 268 | for (var key in now) { 269 | if (!Modifier.EXCLUDE[key]) node.append(Node.create(now[key], node)) 270 | } 271 | } 272 | } else if (change.change == 'remove') { 273 | // Remove all children. 274 | if (prop == 'children') { 275 | path = change.path.slice(0, change.path.length - 1) 276 | node = keypath(this.node.children, path) 277 | node.removeChildren() 278 | } else { 279 | path = change.path 280 | node = keypath(this.node.children, path) 281 | if (node) node.parent.removeChild(node) 282 | } 283 | } 284 | } 285 | 286 | /** 287 | * Modify attributes. 288 | * 289 | * @param {Change} change 290 | * @param {String} prop 291 | * @api private 292 | */ 293 | Modifier.prototype.attributes = function(change, prop) { 294 | var now = change.values.now 295 | var path 296 | var node 297 | 298 | if (change.change == 'add') { 299 | if (prop == 'attributes') { 300 | path = change.path.slice(0, change.path.length - 1) 301 | node = keypath(this.node.children, path) 302 | node.setAttributes(now) 303 | } else { 304 | path = change.path.slice(0, change.path.length - 2) 305 | node = keypath(this.node.children, path) 306 | node.setAttribute(prop, now) 307 | } 308 | } else if (change.change == 'update') { 309 | path = change.path.slice(0, change.path.length - 2) 310 | node = keypath(this.node.children, path) 311 | node.setAttribute(prop, now) 312 | } else if (change.change == 'remove') { 313 | if (prop == 'attributes') { 314 | path = change.path.slice(0, change.path.length - 1) 315 | node = keypath(this.node.children, path) 316 | for (prop in change.values.original) { 317 | node.removeAttribute(prop) 318 | } 319 | } else { 320 | path = change.path.slice(0, change.path.length - 2) 321 | node = keypath(this.node.children, path) 322 | node.removeAttribute(prop) 323 | } 324 | } 325 | } 326 | 327 | /** 328 | * Change tag name. 329 | * 330 | * @param {Change} change 331 | * @param {String} prop 332 | * @api private 333 | */ 334 | Modifier.prototype.name = function(change, prop) { 335 | var path = change.path.slice(0, change.path.length - 1) 336 | var node = keypath(this.node.children, path) 337 | var now = change.values.now 338 | node.setName(now) 339 | } 340 | 341 | },{"./keypath":5,"./node":7}],7:[function(_dereq_,module,exports){ 342 | 'use strict' 343 | 344 | var ElementsPool = _dereq_('./elements-pool') 345 | var queue = _dereq_('./render-queue') 346 | 347 | // Global elements pool for all nodes and all renderer instances. 348 | var pool = new ElementsPool() 349 | 350 | var counter = 0 351 | 352 | var nodesMap = {} 353 | 354 | var ID_NAMESPACE = '__diffRendererId__' 355 | 356 | /** 357 | * Abstract node which can be rendered to a dom node. 358 | * 359 | * @param {Object} options 360 | * @param {String} options.name tag name 361 | * @param {String} [options.text] text for the text node 362 | * @param {Object} [options.attributes] key value hash of name/values 363 | * @param {Element} [options.element] if element is passed, node is already rendered 364 | * @param {Object} [options.children] NodeList like collection 365 | * @param {Node} [parent] 366 | * @api private 367 | */ 368 | function Node(options, parent) { 369 | this.id = counter++ 370 | this.name = options.name 371 | this.parent = parent 372 | if (options.text) this.text = options.text 373 | if (options.attributes) this.attributes = options.attributes 374 | if (options.element) { 375 | this.setTarget(options.element) 376 | // Not dirty if element passed. 377 | } else { 378 | this.dirty('name', true) 379 | if (this.text) this.dirty('text', true) 380 | if (this.attributes) this.dirty('attributes', this.attributes) 381 | } 382 | 383 | if (options.children) { 384 | this.children = [] 385 | for (var i in options.children) { 386 | if (i != 'length') { 387 | this.children[i] = Node.create(options.children[i], this) 388 | if (this.children[i].dirty()) this.dirty('children', true) 389 | } 390 | } 391 | } 392 | 393 | nodesMap[this.id] = this 394 | } 395 | 396 | module.exports = Node 397 | 398 | /** 399 | * Create Node instance, check if passed element has already a Node. 400 | * 401 | * @see {Node} 402 | * @api private 403 | * @return {Node} 404 | */ 405 | Node.create = function(options, parent) { 406 | if (options.element && options.element[ID_NAMESPACE]) { 407 | return nodesMap[options.element[ID_NAMESPACE]] 408 | } 409 | return new Node(options, parent) 410 | } 411 | 412 | /** 413 | * Serialize instance to json data. 414 | * 415 | * @return {Object} 416 | * @api private 417 | */ 418 | Node.prototype.toJSON = function() { 419 | var json = {name: this.name} 420 | if (this.text) json.text = this.text 421 | if (this.attributes) json.attributes = this.attributes 422 | if (this.children) { 423 | json.children = {length: this.children.length} 424 | for (var i = 0; i < this.children.length; i++) { 425 | json.children[i] = this.children[i].toJSON() 426 | } 427 | } 428 | 429 | return json 430 | } 431 | 432 | /** 433 | * Allocate, setup current target, insert children to the dom. 434 | * 435 | * @api private 436 | */ 437 | Node.prototype.render = function() { 438 | if (!this._dirty) return 439 | if (this.dirty('name')) { 440 | if (this.target) this.migrate() 441 | else this.setTarget(pool.allocate(this.name)) 442 | this.dirty('name', null) 443 | } 444 | 445 | // Handle children 446 | if (this.dirty('children') && this.children) { 447 | var newChildren = [] 448 | for (var i = 0; i < this.children.length; i++) { 449 | var child = this.children[i] 450 | // Children can be dirty for removal or for insertions only. 451 | // All other changes are handled by the child.render. 452 | if (child.dirty()) { 453 | if (child.dirty('remove')) { 454 | this.removeChildAt(i) 455 | child.dirty('remove', null) 456 | delete nodesMap[child.id] 457 | // Handle insert. 458 | } else { 459 | var next = this.children[i + 1] 460 | child.render() 461 | this.target.insertBefore(child.target, next && next.target) 462 | newChildren.push(child) 463 | child.dirty('insert', null) 464 | child.dirty('name', null) 465 | } 466 | } else { 467 | newChildren.push(child) 468 | } 469 | } 470 | // We migrate children to the new array because some of them might be removed 471 | // and if we splice them directly, we will remove wrong elements. 472 | if (newChildren) this.children = newChildren 473 | this.dirty('children', null) 474 | } 475 | 476 | // Handle textContent. 477 | if (this.dirty('text') && this.text) { 478 | this.target.textContent = this.text 479 | this.dirty('text', null) 480 | } 481 | 482 | // Handle attribtues. 483 | if (this.dirty('attributes')) { 484 | var attributes = this.dirty('attributes') 485 | for (var attrName in attributes) { 486 | var value = attributes[attrName] 487 | if (value == null) { 488 | delete this.attributes[attrName] 489 | if (this.name != '#text') this.target.removeAttribute(attrName) 490 | } else { 491 | if (!this.attributes) this.attributes = {} 492 | this.attributes[attrName] = value 493 | this.target.setAttribute(attrName, value) 494 | } 495 | } 496 | this.dirty('attributes', null) 497 | } 498 | } 499 | 500 | /** 501 | * Remove child DOM element at passed position without removing from children array. 502 | * 503 | * @param {Number} position 504 | * @api private 505 | */ 506 | Node.prototype.removeChildAt = function(position) { 507 | var child = this.children[position] 508 | child.detach() 509 | child.cleanup() 510 | if (child.children) { 511 | for (var i = 0; i < child.children.length; i++) { 512 | child.removeChildAt(i) 513 | } 514 | } 515 | pool.deallocate(child.target) 516 | child.unlink() 517 | } 518 | 519 | /** 520 | * Migrate target DOM element and its children to a new DOM element. 521 | * F.e. because tagName changed. 522 | * 523 | * @api private 524 | */ 525 | Node.prototype.migrate = function() { 526 | this.detach() 527 | this.cleanup() 528 | var oldTarget = this.target 529 | this.setTarget(pool.allocate(this.name)) 530 | 531 | // Migrate children. 532 | if (this.name == '#text') { 533 | this.children = null 534 | } else { 535 | this.text = null 536 | while (oldTarget.hasChildNodes()) { 537 | this.target.appendChild(oldTarget.removeChild(oldTarget.firstChild)) 538 | } 539 | } 540 | pool.deallocate(oldTarget) 541 | this.dirty('insert', true) 542 | } 543 | 544 | /** 545 | * Remove target DOM element from the render tree. 546 | * 547 | * @api private 548 | */ 549 | Node.prototype.detach = function() { 550 | var parentNode = this.target.parentNode 551 | if (parentNode) parentNode.removeChild(this.target) 552 | } 553 | 554 | /** 555 | * Clean up everything changed on the target DOM element. 556 | * 557 | * @api private 558 | */ 559 | Node.prototype.cleanup = function() { 560 | if (this.attributes) { 561 | for (var attrName in this.attributes) this.target.removeAttribute(attrName) 562 | } 563 | if (this.text) this.target.textContent = '' 564 | delete this.target[ID_NAMESPACE] 565 | } 566 | 567 | /** 568 | * Set all child nodes set dirty for removal. 569 | * 570 | * @api private 571 | */ 572 | Node.prototype.removeChildren = function() { 573 | if (!this.children) return 574 | 575 | for (var i = 0; i < this.children.length; i++) { 576 | this.children[i].dirty('remove', true) 577 | } 578 | 579 | this.dirty('children', true) 580 | } 581 | 582 | /** 583 | * Set child element as dirty for removal. 584 | * 585 | * @param {Node} node 586 | * @api private 587 | */ 588 | Node.prototype.removeChild = function(child) { 589 | child.dirty('remove', true) 590 | this.dirty('children', true) 591 | } 592 | 593 | /** 594 | * Clean up all references for current and child nodes. 595 | * 596 | * @api private 597 | */ 598 | Node.prototype.unlink = function() { 599 | if (this.children) { 600 | for (var i = 0; i < this.children.length; i++) { 601 | this.children[i].unlink() 602 | } 603 | } 604 | delete this.id 605 | delete this.name 606 | delete this.text 607 | delete this.attributes 608 | delete this.parent 609 | delete this.children 610 | delete this.target 611 | delete this._dirty 612 | } 613 | 614 | /** 615 | * Insert a node at specified position. 616 | * 617 | * @param {Number} position 618 | * @param {Node} node 619 | * @api private 620 | */ 621 | Node.prototype.insertAt = function(position, node) { 622 | this.dirty('children', true) 623 | node.dirty('insert', true) 624 | if (!this.children) this.children = [] 625 | this.children.splice(position, 0, node) 626 | } 627 | 628 | /** 629 | * Insert a node at the end. 630 | * 631 | * @param {Node} node 632 | * @api private 633 | */ 634 | Node.prototype.append = function(node) { 635 | var position = this.children ? this.children.length : 0 636 | this.insertAt(position, node) 637 | } 638 | 639 | /** 640 | * Set nodes attributes. 641 | * 642 | * @param {Object} attribtues 643 | * @api private 644 | */ 645 | Node.prototype.setAttributes = function(attributes) { 646 | for (var name in attributes) { 647 | this.setAttribute(name, attributes[name]) 648 | } 649 | } 650 | 651 | /** 652 | * Set nodes attribute. 653 | * 654 | * @param {String} name 655 | * @param {String|Boolean|Null} value, use null to remove 656 | * @api private 657 | */ 658 | Node.prototype.setAttribute = function(name, value) { 659 | if (this.attributes && this.attributes[name] == value) return 660 | var attributes = this.dirty('attributes') || {} 661 | attributes[name] = value 662 | this.dirty('attributes', attributes) 663 | } 664 | 665 | /** 666 | * Remove nodes attribute. 667 | * @param {String} name 668 | * @api private 669 | */ 670 | Node.prototype.removeAttribute = function(name) { 671 | if (this.attributes && this.attributes[name] != null) this.setAttribute(name, null) 672 | } 673 | 674 | /** 675 | * Set text content. 676 | * 677 | * @param {String} content 678 | * @api private 679 | */ 680 | Node.prototype.setText = function(text) { 681 | if (this.name != '#text' || text == this.text) return 682 | this.dirty('text', true) 683 | this.text = text 684 | } 685 | 686 | /** 687 | * Element name can't be set, we need to swap out the element. 688 | * 689 | * @param {String} name 690 | * @api private 691 | */ 692 | Node.prototype.setName = function(name) { 693 | if (name == this.name) return 694 | this.dirty('name', true) 695 | this.parent.dirty('children', true) 696 | this.name = name 697 | } 698 | 699 | /** 700 | * Set target element. 701 | * 702 | * @param {Element} element 703 | * @api private 704 | */ 705 | Node.prototype.setTarget = function(element) { 706 | element[ID_NAMESPACE] = this.id 707 | this.target = element 708 | } 709 | 710 | /** 711 | * Get/set/unset a dirty flag, add to render queue. 712 | * 713 | * @param {String} name 714 | * @param {Mixed} [value] 715 | * @api private 716 | */ 717 | Node.prototype.dirty = function(name, value) { 718 | // Get flag. 719 | if (value === undefined) { 720 | return this._dirty && name ? this._dirty[name] : this._dirty 721 | } 722 | 723 | // Unset dirty flag. 724 | if (value === null) { 725 | if (this._dirty) { 726 | delete this._dirty[name] 727 | // If if its not empty object - exist. 728 | for (name in this._dirty) return 729 | // For quick check. 730 | delete this._dirty 731 | } 732 | // Set dirty flag. 733 | } else { 734 | if (!this._dirty) { 735 | this._dirty = {} 736 | // Only enqueue if its the first flag. 737 | queue.enqueue(this) 738 | } 739 | this._dirty[name] = value 740 | } 741 | } 742 | 743 | },{"./elements-pool":3,"./render-queue":8}],8:[function(_dereq_,module,exports){ 744 | 'use strict' 745 | 746 | /** 747 | * Any changed nodes land here to get considered for rendering. 748 | * 749 | * @type {Array} 750 | * @api private 751 | */ 752 | var queue = module.exports = [] 753 | 754 | /** 755 | * Add node to the queue. 756 | * 757 | * @param {Node} node 758 | * @api private 759 | */ 760 | queue.enqueue = function(node) { 761 | queue.push(node) 762 | } 763 | 764 | /** 765 | * Empty the queue. 766 | * 767 | * @param {Node} node 768 | * @api private 769 | */ 770 | queue.empty = function() { 771 | queue.splice(0) 772 | } 773 | 774 | 775 | },{}],9:[function(_dereq_,module,exports){ 776 | 'use strict' 777 | 778 | var docdiff = _dereq_('docdiff') 779 | var keypath = _dereq_('./keypath') 780 | var Node = _dereq_('./node') 781 | var Modifier = _dereq_('./modifier') 782 | var serializeDom = _dereq_('./serialize-dom') 783 | var serializeHtml = _dereq_('./serialize-html') 784 | var renderQueue = _dereq_('./render-queue') 785 | var hashify = _dereq_('./hashify') 786 | 787 | /** 788 | * Renderer constructor. 789 | * 790 | * @param {Element} element dom node for serializing and updating. 791 | * @api public 792 | */ 793 | function Renderer(element) { 794 | if (!element) throw new TypeError('DOM element required') 795 | if (!(this instanceof Renderer)) return new Renderer(element) 796 | this.node = null 797 | this.modifier = null 798 | this.refresh(element) 799 | } 800 | 801 | module.exports = Renderer 802 | 803 | Renderer.serializeDom = serializeDom 804 | Renderer.serializeHtml = serializeHtml 805 | Renderer.keypath = keypath 806 | Renderer.docdiff = docdiff 807 | Renderer.hashify = hashify 808 | 809 | /** 810 | * Start checking render queue and render. 811 | * 812 | * @api public 813 | */ 814 | Renderer.start = function() { 815 | function check() { 816 | if (!Renderer.running) return 817 | Renderer.render() 818 | requestAnimationFrame(check) 819 | } 820 | 821 | Renderer.running = true 822 | requestAnimationFrame(check) 823 | } 824 | 825 | /** 826 | * Stop checking render queue and render. 827 | * 828 | * @api public 829 | */ 830 | Renderer.stop = function() { 831 | Renderer.running = false 832 | } 833 | 834 | /** 835 | * Render all queued nodes. 836 | * 837 | * @api public 838 | */ 839 | Renderer.render = function() { 840 | if (!renderQueue.length) return 841 | for (var i = 0; i < renderQueue.length; i++) { 842 | renderQueue[i].render() 843 | } 844 | renderQueue.empty() 845 | 846 | return this 847 | } 848 | 849 | /** 850 | * Create a snapshot from the dom. 851 | * 852 | * @param {Element} [element] 853 | * @return {Renderer} this 854 | * @api public 855 | */ 856 | Renderer.prototype.refresh = function(element) { 857 | if (!element && this.node) element = this.node.target 858 | if (this.node) this.node.unlink() 859 | var json = serializeDom(element) 860 | this.node = Node.create(json) 861 | this.modifier = new Modifier(this.node) 862 | 863 | return this 864 | } 865 | 866 | /** 867 | * Find changes and apply them to virtual nodes. 868 | * 869 | * @param {String} html 870 | * @return {Renderer} this 871 | * @api public 872 | */ 873 | Renderer.prototype.update = function(html) { 874 | var next = serializeHtml(html).children 875 | // Everything has been removed. 876 | if (!next) { 877 | this.node.removeChildren() 878 | return this 879 | } 880 | var current = this.node.toJSON().children || {} 881 | var diff = docdiff(current, next) 882 | this.modifier.apply(diff) 883 | 884 | return this 885 | } 886 | 887 | },{"./hashify":4,"./keypath":5,"./modifier":6,"./node":7,"./render-queue":8,"./serialize-dom":10,"./serialize-html":11,"docdiff":13}],10:[function(_dereq_,module,exports){ 888 | 'use strict' 889 | 890 | /** 891 | * Walk through the dom and create a json snapshot. 892 | * 893 | * @param {Element} element 894 | * @return {Object} 895 | * @api private 896 | */ 897 | module.exports = function serialize(element) { 898 | var json = { 899 | name: element.nodeName.toLowerCase(), 900 | element: element 901 | } 902 | 903 | if (json.name == '#text') { 904 | json.text = element.textContent 905 | return json 906 | } 907 | 908 | var attr = element.attributes 909 | if (attr && attr.length) { 910 | json.attributes = {} 911 | var attrLength = attr.length 912 | for (var i = 0; i < attrLength; i++) { 913 | json.attributes[attr[i].name] = attr[i].value 914 | } 915 | } 916 | 917 | var childNodes = element.childNodes 918 | if (childNodes && childNodes.length) { 919 | json.children = {length: childNodes.length} 920 | var childNodesLength = childNodes.length 921 | for (var i = 0; i < childNodesLength; i++) { 922 | json.children[i] = serialize(childNodes[i]) 923 | } 924 | } 925 | 926 | return json 927 | } 928 | 929 | },{}],11:[function(_dereq_,module,exports){ 930 | 'use strict' 931 | 932 | /** 933 | * Simplified html parser. The fastest one written in javascript. 934 | * It is naive and requires valid html. 935 | * You might want to validate your html before to pass it here. 936 | * 937 | * @param {String} html 938 | * @param {Object} [parent] 939 | * @return {Object} 940 | * @api private 941 | */ 942 | module.exports = function serialize(str, parent) { 943 | if (!parent) parent = {name: 'root'} 944 | if (!str) return parent 945 | 946 | var i = 0 947 | var end = false 948 | var added = false 949 | var current 950 | var isWhite, isSlash, isOpen, isClose 951 | var inTag = false 952 | var inTagName = false 953 | var inAttrName = false 954 | var inAttrValue = false 955 | var inCloser = false 956 | var inClosing = false 957 | var isQuote, openQuote 958 | var attrName, attrValue 959 | var inText = false 960 | 961 | var json = { 962 | parent: parent, 963 | name: '' 964 | } 965 | 966 | while (!end) { 967 | current = str[i] 968 | isWhite = current == ' ' || current == '\t' || current == '\r' || current == '\n' 969 | isSlash = current == '/' 970 | isOpen = current == '<' 971 | isClose = current == '>' 972 | isQuote = current == "'" || current == '"' 973 | if (isSlash) inClosing = true 974 | if (isClose) inCloser = false 975 | 976 | if (current == null) { 977 | end = true 978 | } else { 979 | if (inTag) { 980 | if (inCloser) { 981 | delete json.name 982 | // Tag name 983 | } else if (inTagName || !json.name) { 984 | inTagName = true 985 | if ((json.name && isWhite) || isSlash) { 986 | inTagName = false 987 | if (!json.name) { 988 | inCloser = true 989 | if (parent.parent) parent = parent.parent 990 | } 991 | } else if (isClose) { 992 | serialize(str.substr(i + 1), inClosing || inCloser ? parent : json) 993 | return parent 994 | } else if (!isWhite) { 995 | json.name += current 996 | } 997 | // Attribute name 998 | } else if (inAttrName || !attrName) { 999 | inAttrName = true 1000 | if (attrName == null) attrName = '' 1001 | if (isSlash || 1002 | (attrName && isWhite) || 1003 | (attrName && current == '=')) { 1004 | 1005 | inAttrName = false 1006 | if (attrName) { 1007 | if (!json.attributes) json.attributes = {} 1008 | json.attributes[attrName] = '' 1009 | } 1010 | } else if (isClose) { 1011 | serialize(str.substr(i + 1), inClosing || inCloser ? parent : json) 1012 | return parent 1013 | } else if (!isWhite) { 1014 | attrName += current 1015 | } 1016 | // Attribute value 1017 | } else if (inAttrValue || attrName) { 1018 | if (attrValue == null) attrValue = '' 1019 | 1020 | if (isQuote) { 1021 | if (inAttrValue) { 1022 | if (current == openQuote) { 1023 | if (attrValue) json.attributes[attrName] = attrValue 1024 | inAttrValue = false 1025 | attrName = attrValue = null 1026 | } else { 1027 | attrValue += current 1028 | } 1029 | } else { 1030 | inAttrValue = true 1031 | openQuote = current 1032 | } 1033 | } else if (inAttrValue) { 1034 | attrValue += current 1035 | } 1036 | } 1037 | } else if (isOpen) { 1038 | if (inText) { 1039 | serialize(str.substr(i), parent) 1040 | return parent 1041 | } 1042 | inTag = true 1043 | } else if (isSlash && !inAttrValue) { 1044 | end = true 1045 | } else { 1046 | inText = true 1047 | inTag = false 1048 | if (!json.name) json.name = '#text' 1049 | if (json.text == null) json.text = '' 1050 | json.text += current 1051 | } 1052 | 1053 | if (json.name && !added) { 1054 | if (!parent.children) parent.children = {length: 0} 1055 | parent.children[parent.children.length] = json 1056 | parent.children.length++ 1057 | added = true 1058 | } 1059 | } 1060 | 1061 | if (isClose) inClosing = false 1062 | 1063 | ++i 1064 | } 1065 | 1066 | return parent 1067 | } 1068 | 1069 | },{}],12:[function(_dereq_,module,exports){ 1070 | 1071 | var utils = _dereq_('./utils'); 1072 | 1073 | /** 1074 | * Diff Arrays 1075 | * 1076 | * @param {Array} one 1077 | * @param {Array} two 1078 | * @return {Array} Array with values in one but not in two 1079 | */ 1080 | var diffArrays = function (one, two) { 1081 | return one.filter(function (val) { 1082 | return two.indexOf(val) === -1; 1083 | }); 1084 | }; 1085 | 1086 | /** 1087 | * Extract Type 1088 | * 1089 | * Returns a function that can be passed to an iterator (forEach) that will 1090 | * correctly update all.primitives and all.documents based on the values it 1091 | * iteraties over 1092 | * 1093 | * @param {Object} all Object on which primitives/documents will be set 1094 | * @return {Object} The all object, updated based on the looped values 1095 | */ 1096 | var extractType = function (all) { 1097 | return function (val) { 1098 | if (utils.isObject(val)) { 1099 | all.primitives = false; 1100 | } else { 1101 | all.documents = false; 1102 | } 1103 | 1104 | if (Array.isArray(val)) 1105 | all.primitives = false; 1106 | } 1107 | }; 1108 | 1109 | /** 1110 | * ArrayDiff 1111 | * 1112 | * @param {Array} original 1113 | * @param {Array} now 1114 | * @return {Object} 1115 | */ 1116 | module.exports = function (original, now) { 1117 | 1118 | var all = { primitives: true, documents: true }; 1119 | 1120 | original.forEach(extractType(all)); 1121 | now.forEach(extractType(all)); 1122 | 1123 | var diff = { 1124 | change: null, 1125 | now: now, 1126 | original: original 1127 | }; 1128 | 1129 | if (all.primitives) { 1130 | diff.change = 'primitiveArray'; 1131 | diff.added = diffArrays(now, original); 1132 | diff.removed = diffArrays(original, now); 1133 | } else { 1134 | diff.change = all.documents ? 'documentArray' : 'mixedArray'; 1135 | } 1136 | 1137 | return diff; 1138 | }; 1139 | },{"./utils":14}],13:[function(_dereq_,module,exports){ 1140 | 1141 | var arraydiff = _dereq_('./arraydiff'); 1142 | var utils = _dereq_('./utils'); 1143 | 1144 | /** 1145 | * DocDiff 1146 | * 1147 | * @param {Object} original 1148 | * @param {Object} now 1149 | * @param {Array} path 1150 | * @param {Array} changes 1151 | * @return {Array} Array of changes 1152 | */ 1153 | module.exports = function docdiff (original, now, path, changes) { 1154 | if (!original || !now) 1155 | return false; 1156 | 1157 | if (!path) 1158 | path = []; 1159 | 1160 | if (!changes) 1161 | changes = []; 1162 | 1163 | var keys = Object.keys(now); 1164 | keys.forEach(function (key) { 1165 | var newVal = now[key]; 1166 | var origVal = original[key]; 1167 | 1168 | // Recurse 1169 | if (utils.isObject(newVal) && utils.isObject(origVal)) { 1170 | return docdiff(origVal, newVal, path.concat(key), changes); 1171 | } 1172 | 1173 | // Array diff 1174 | if (Array.isArray(newVal) && Array.isArray(origVal)) { 1175 | var diff = arraydiff(origVal, newVal); 1176 | return changes.push(new Change(path, key, 'update', diff.change, diff.now, 1177 | diff.original, diff.added, diff.removed)); 1178 | } 1179 | 1180 | // Primitive updates and additions 1181 | if (origVal !== newVal) { 1182 | var type = origVal === undefined ? 'add' : 'update'; 1183 | changes.push(new Change(path, key, type, 'primitive', newVal, origVal)); 1184 | } 1185 | }); 1186 | 1187 | // Primitve removals 1188 | Object.keys(original).forEach(function (key) { 1189 | if (keys.indexOf(key) === -1) 1190 | changes.push(new Change(path, key, 'remove', 'primitive', null, 1191 | original[key])); 1192 | }); 1193 | 1194 | return changes; 1195 | } 1196 | 1197 | /** 1198 | * Change 1199 | * 1200 | * @param {Array} path 1201 | * @param {String} key 1202 | * @param {String} change 1203 | * @param {String} type 1204 | * @param {Mixed} now 1205 | * @param {Mixed} original 1206 | * @param {Array} added 1207 | * @param {Array} removed 1208 | */ 1209 | function Change (path, key, change, type, now, original, added, removed) { 1210 | this.path = path.concat(key); 1211 | this.change = change; 1212 | this.type = type; 1213 | 1214 | this.values = {}; 1215 | 1216 | if (change !== 'remove') 1217 | this.values.now = now; 1218 | 1219 | if (change !== 'add') 1220 | this.values.original = original; 1221 | 1222 | if (type === 'primitiveArray') { 1223 | this.values.added = added; 1224 | this.values.removed = removed; 1225 | } 1226 | } 1227 | 1228 | },{"./arraydiff":12,"./utils":14}],14:[function(_dereq_,module,exports){ 1229 | 1230 | /** 1231 | * isObject 1232 | * 1233 | * @param {Mixed} arg 1234 | * @return {Boolean} If arg is an object 1235 | */ 1236 | exports.isObject = function (arg) { 1237 | return typeof arg === 'object' && arg !== null && !Array.isArray(arg); 1238 | }; 1239 | 1240 | },{}]},{},[1]) 1241 | (1) 1242 | }); --------------------------------------------------------------------------------