├── .gitignore ├── examples ├── todolist2 │ ├── components.css │ ├── components.js │ ├── todo.html │ └── components.html ├── todolist3 │ ├── todo.css │ ├── todo.html │ └── mycustomdirectives.js ├── viewsource.html ├── todolist │ └── todo.html └── todomvc │ └── todo.html ├── package.json ├── bower.json ├── LICENSE ├── README.md └── databind.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /examples/todolist2/components.css: -------------------------------------------------------------------------------- 1 | .todo-filters span { 2 | margin-right: 10px; 3 | } 4 | .todo-filters span:not(.active) { 5 | color: blue; 6 | cursor: pointer; 7 | text-decoration: underline; 8 | } 9 | 10 | .todo-list ul { 11 | width: 300px; 12 | padding: 10px; 13 | list-style: none; 14 | line-height: 30px; 15 | background: #f3f3f3; 16 | } 17 | .todo-list input[type=text] { 18 | width: 250px; 19 | } 20 | 21 | .todo-item .text { 22 | width: 250px; 23 | } 24 | .todo-item .delete-button { 25 | float: right; 26 | cursor: pointer; 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "databind", 3 | "version": "0.0.5", 4 | "description": "A powerful, flexible data binding library", 5 | "keywords": ["mvc", "view", "data", "binding", "template", "component", "framework"], 6 | "license": "MIT", 7 | "author": "Hai Phan ", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/ken107/databind-js" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/ken107/databind-js/issues" 14 | }, 15 | "main": "databind.js", 16 | "files": [ 17 | "databind.js", 18 | "examples" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "databinder", 3 | "version": "0.0.5", 4 | "description": "A powerful, flexible data binding library", 5 | "main": "databind.js", 6 | "moduleType": [ 7 | "globals" 8 | ], 9 | "keywords": [ 10 | "mvc", 11 | "view", 12 | "data", 13 | "binding", 14 | "template", 15 | "component", 16 | "framework" 17 | ], 18 | "authors": [ 19 | "Hai Phan " 20 | ], 21 | "license": "MIT", 22 | "homepage": "https://github.com/ken107/databind-js", 23 | "ignore": [ 24 | "**/.*", 25 | "node_modules", 26 | "bower_components", 27 | "test", 28 | "tests" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /examples/todolist3/todo.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial; 3 | } 4 | h1 { 5 | margin: .3em; 6 | } 7 | .header, .footer { 8 | text-align: center; 9 | } 10 | .filter { 11 | margin: 5px; 12 | } 13 | .filter:not(.active) { 14 | color: blue; 15 | cursor: pointer; 16 | text-decoration: underline; 17 | } 18 | .content { 19 | margin: 1em auto; 20 | width: 300px; 21 | padding: 15px; 22 | list-style: none; 23 | line-height: 30px; 24 | background: #f3f3f3; 25 | border-radius: 5px; 26 | } 27 | .content .text, .content input[type=text] { 28 | width: 250px; 29 | } 30 | .delete-button { 31 | float: right; 32 | cursor: pointer; 33 | } 34 | .view-source { 35 | margin-top: 20px; 36 | text-align: center; 37 | } 38 | -------------------------------------------------------------------------------- /examples/todolist2/components.js: -------------------------------------------------------------------------------- 1 | //controllers 2 | 3 | function Filters() { 4 | } 5 | 6 | function TodoList() { 7 | this.isItemVisible = function(isCompleted, currentFilter) { 8 | return currentFilter == 'All' || 9 | currentFilter == 'Active' && !isCompleted || 10 | currentFilter == 'Completed' && isCompleted; 11 | }; 12 | } 13 | 14 | function TodoItem() { 15 | } 16 | 17 | 18 | //helpers 19 | 20 | function toggleClass(elem, className, toggle) { 21 | if (toggle) elem.className += " " + className; 22 | else elem.className = elem.className.replace(new RegExp("(?:^|\\s)" + className + "(?!\\S)", "g"), ""); 23 | } 24 | 25 | function dispatchEvent(elem, type, data) { 26 | var event; 27 | try { 28 | event = new Event(type); 29 | event.bubbles = false; 30 | } 31 | catch (err) { 32 | event = document.createEvent('Event'); 33 | event.initEvent(type, false, false); 34 | } 35 | event.data = data; 36 | elem.dispatchEvent(event); 37 | } 38 | -------------------------------------------------------------------------------- /examples/viewsource.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 | 29 | 30 | 31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Hai Phan 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 all 13 | 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 THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /examples/todolist2/todo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 34 | 37 | 38 | 39 | 45 |
53 |
54 | 55 | 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## What's This? 2 | Data binding allows you to detect changes to your data and react by updating the DOM. 3 | 4 | ## Installation 5 | First add databind.js to your page. 6 | ```html 7 | 8 | ``` 9 | Or `bower install databinder`. 10 | 11 | ## Detecting Changes To Your Data 12 | Your data is whatever `this` points to, which is by the default the `window` object. Say your window object has the following property: 13 | ```javascript 14 | window.blog = { 15 | name: "My blog", 16 | entries: [ 17 | { title: "...", text: "...", isPublished: true }, 18 | { title: "...", text: "...", isPublished: false } 19 | ] 20 | } 21 | ``` 22 | To bind to the text of the first blog entry, for example, use the _binding expression_ `#blog.entries[0].text`. 23 | 24 | ## Updating the DOM 25 | Set the text content of an element 26 | ```html 27 |

{{#blog.entries[0].title}}

28 |

{{#blog.entries[0].text}}

29 | ``` 30 | 31 | Hide/show an element 32 | ```html 33 |
34 | {{#blog.entries[0].text}} 35 |
36 | ``` 37 | 38 | Change an image 39 | ```html 40 | 41 | ``` 42 | 43 | Toggle a CSS class (using jQuery) 44 | ```html 45 |
  • 46 | {{#blog.entries[0].title}} 47 |
  • 48 | ``` 49 | 50 | Call a function 51 | ```html 52 |
    53 | ``` 54 | 55 | Say you want to repeat an element a number of times 56 | ```html 57 |
    {{#blog.entries[#i].text}}
    58 | ``` 59 | 60 | Set the value of an text box 61 | ```html 62 | 63 | ``` 64 | 65 | Et cetera. 66 | 67 | The `bind-statement` specifies a JavaScript statement that should be executed every time your data changes. It is one of just 6 _binding directives_ that together let you write responsive apps of any complexity. They're no less capable than Angular or React. 68 | 69 | Proceed to the [documentation](https://github.com/ken107/databind-js/wiki/Home) for the full list of binding directives. 70 | 71 | ## Example 72 | http://jsfiddle.net/wcoczs50/4/ 73 | -------------------------------------------------------------------------------- /examples/todolist3/todo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 22 | 23 | 24 |
    25 |

    TODO

    26 |
    27 | {{#filter}} 31 |
    32 |
    33 | 59 | 63 | 64 |
    65 | 67 |
    68 |
    69 |
    70 | Fork me on GitHub 71 |
    72 |
    73 | 74 | 75 | -------------------------------------------------------------------------------- /examples/todolist2/components.html: -------------------------------------------------------------------------------- 1 |
    4 | {{#filter}} 9 |
    10 | 11 | 12 |
  • 14 |
    X
    16 | 19 | {{#text}} 23 | 29 |
  • 30 | 31 | 32 |
    35 |

    TODO

    36 |
    39 |
    40 |
      41 |
    • 42 | 45 | 47 |
    • 48 |
    • 60 |
    61 |
    62 | 64 |
    65 |
    66 | -------------------------------------------------------------------------------- /examples/todolist/todo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 31 | 32 | 46 | 47 | 48 |

    TODO

    49 |
    50 | {{#filters[#i]}} 54 |
    55 | 85 |
    86 | 88 |
    89 | 90 | 91 | -------------------------------------------------------------------------------- /examples/todolist3/mycustomdirectives.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var my = { 3 | onenterkey: "x-onenterkey", 4 | visible: "x-visible", 5 | linethrough: "x-linethrough", 6 | focus: "x-focus", 7 | checked: "x-checked", 8 | value: "x-value", 9 | disabled: "x-disabled", 10 | if: "x-if", 11 | foreach: "x-foreach-", 12 | attr: "x-attr-", 13 | style: "x-style-", 14 | toggleclass: "x-toggleclass-", 15 | onevent: "x-on" 16 | }; 17 | 18 | dataBinder.onDataBinding = function(node) { 19 | var toRemove = []; 20 | for (var i=0; i 2 | 3 | 4 | 37 | 38 | 53 | 54 | 55 |

    TODO

    56 |
    57 | {{#filters[#i]}} 61 |
    62 |
      63 |
    • 64 | 68 | 70 |
    • 71 |
    • 73 |
      X
      75 | 78 | {{#item.text}} 82 | 88 |
    • 89 |
    90 |
    92 | 94 |
    95 |
    96 | {{#updateFlag, #items.filter(function(item) {return !item.completed}).length}} remaining 97 |
    98 | 99 |
    100 |
    102 |
    103 | 104 | 105 | -------------------------------------------------------------------------------- /databind.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DataBinder 3 | * Copyright 2015, Hai Phan 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | */ 8 | (function() { 9 | const prefix = "{{" 10 | const suffix = "}}" 11 | const regex = { 12 | textBindExpr: new RegExp(prefix + "[\\s\\S]*?" + suffix ,"g"), 13 | singlePart: /^\s*parts\[0\].get\(\)\s*$/, 14 | bindingSource: /'.*?'|".*?"|#\w+(?:\.\w+|\[(?:.*?\[.*?\])*?[^\[]*?\])*(\s*(?:\+\+|--|\+=|-=|\*=|\/=|%=|=(?!=)|\())?/g, 15 | propertyAccessor: /\.\w+|\[(?:.*?\[.*?\])*?[^\[]*?\]/g, 16 | thisMethodCall: /\bthis\.(\w+)\s*\(/g, 17 | kebab: /-([a-z])/g, 18 | nonExpr: /;\s*\S/, 19 | allDigits: /^\d+$/, 20 | } 21 | const propPrefix = "__prop__" 22 | const exprCache = {} 23 | const varDependencyCache = new Map() 24 | const unreadyViews = new Set() 25 | 26 | /** 27 | * Helpers 28 | */ 29 | function immediate(func) { 30 | return func() 31 | } 32 | 33 | const callLater = immediate(() => { 34 | let queue = null 35 | function call() { 36 | while (queue.min <= queue.max) { 37 | if (queue[queue.min]) { 38 | const funcs = queue[queue.min] 39 | queue[queue.min++] = null 40 | for (const func of funcs) func() 41 | } else { 42 | queue.min++ 43 | } 44 | } 45 | queue = null 46 | } 47 | return function(func, priority) { 48 | if (!queue) { 49 | queue = {min: priority, max: priority}; 50 | (window.queueMicrotask || setTimeout)(call) 51 | } 52 | (queue[priority] || (queue[priority] = new Set())).add(func) 53 | if (priority < queue.min) queue.min = priority 54 | if (priority > queue.max) queue.max = priority 55 | } 56 | }) 57 | 58 | const timer = immediate(() => { 59 | let queue = null 60 | let counter = 0 61 | let intervalId = 0 62 | function onTimer() { 63 | const now = Date.now() 64 | for (const [id, {callback, expires}] of queue) { 65 | if (expires <= now) { 66 | callback() 67 | queue.delete(id) 68 | } 69 | } 70 | if (queue.size == 0) { 71 | queue = null; 72 | clearInterval(intervalId); 73 | } 74 | } 75 | return { 76 | callAfter(timeout, func) { 77 | if (!queue) { 78 | queue = new Map() 79 | intervalId = setInterval(onTimer, api.timerInterval); 80 | } 81 | const id = ++counter 82 | queue.set(id, {expires: Date.now()+timeout, callback: func}) 83 | return id; 84 | }, 85 | cancel(id) { 86 | if (queue) queue.delete(id) 87 | } 88 | } 89 | }) 90 | 91 | function getDirectives(node) { 92 | const dirs = {params: [], vars: [], statements: [], events: [], toRemove: []} 93 | for (const attr of node.attributes) { 94 | if (attr.specified) { 95 | if (attr.name == api.directives.bindView) { 96 | dirs.view = attr.value; 97 | dirs.toRemove.push(attr.name); 98 | } 99 | else if (attr.name.startsWith(api.directives.bindParameter)) { 100 | dirs.params.push({ 101 | name: toCamelCase(attr.name.slice(api.directives.bindParameter.length)), 102 | value: attr.value 103 | }) 104 | dirs.toRemove.push(attr.name); 105 | } 106 | else if (attr.name.startsWith(api.directives.bindVariable)) { 107 | dirs.vars.push({ 108 | name: toCamelCase(attr.name.slice(api.directives.bindVariable.length)), 109 | value: attr.value 110 | }) 111 | dirs.toRemove.push(attr.name); 112 | } 113 | else if (attr.name.startsWith(api.directives.bindStatement)) { 114 | dirs.statements.push({ 115 | value: "; " + attr.value 116 | }) 117 | dirs.toRemove.push(attr.name); 118 | } 119 | else if (attr.name.startsWith(api.directives.bindEvent)) { 120 | dirs.events.push({ 121 | name: attr.name.slice(api.directives.bindEvent.length), 122 | value: "; " + attr.value 123 | }) 124 | dirs.toRemove.push(attr.name); 125 | } 126 | else if (attr.name.startsWith(api.directives.bindRepeater)) { 127 | dirs.repeater = { 128 | name: toCamelCase(attr.name.slice(api.directives.bindRepeater.length)), 129 | value: attr.value, 130 | view: node.getAttribute(api.directives.bindView) 131 | } 132 | dirs.toRemove = [attr.name]; 133 | break; 134 | } 135 | } 136 | } 137 | if (dirs.vars.length > 1) { 138 | const cacheKey = dirs.vars.map(({ name, value }) => name + value).join('') 139 | let cacheValue = varDependencyCache.get(cacheKey) 140 | if (!cacheValue) varDependencyCache.set(cacheKey, cacheValue = sortByDependency(dirs.vars)) 141 | dirs.vars = cacheValue 142 | } 143 | return dirs; 144 | } 145 | 146 | function sortByDependency(vars) { 147 | const input = vars.map(x => new RegExp('#' + x.name + '\\b')) 148 | const sorted = [] 149 | while (sorted.length < input.length) { 150 | let i = input.length - 1 151 | while (i >= 0 && (!input[i] || vars.some((x, j) => input[j] && j != i && input[i].test(x.value)))) i-- 152 | if (i < 0) { 153 | console.log(vars) 154 | throw new Error('Circular dependency detected') 155 | } 156 | sorted.unshift(vars[i]) 157 | input[i] = null 158 | } 159 | return sorted 160 | } 161 | 162 | function removeDirectives(node, dirs) { 163 | for (const name of dirs.toRemove) node.removeAttribute(name) 164 | } 165 | 166 | function toCamelCase(str) { 167 | return str.replace(regex.kebab, g => g[1].toUpperCase()) 168 | } 169 | 170 | function noOp() { 171 | } 172 | 173 | function illegalOp() { 174 | throw new Error("Illegal operation"); 175 | } 176 | 177 | function printDebug(debugInfo) { 178 | if (debugInfo.length) console.log(debugInfo) 179 | } 180 | 181 | function proxy(func, ctx) { 182 | const args = Array.prototype.slice.call(arguments, 2) 183 | return function() { 184 | return func.apply(ctx, args.concat(Array.prototype.slice.call(arguments))); 185 | }; 186 | } 187 | 188 | function makeEventHandler(node, type, scope, prop) { 189 | function handler(event) { 190 | scope.event = event; 191 | const val = prop.get() 192 | if (val == false && event) { 193 | if (event.preventDefault instanceof Function) event.preventDefault(); 194 | if (event.stopPropagation instanceof Function) event.stopPropagation(); 195 | } 196 | return val; 197 | } 198 | function jQueryHandler(event) { 199 | event.data = arguments.length > 2 ? Array.prototype.slice.call(arguments, 1) : arguments[1]; 200 | scope.event = event; 201 | return prop.get(); 202 | } 203 | const camel = toCamelCase(type) 204 | if (window.jQuery) { 205 | jQuery(node).on(type, jQueryHandler); 206 | if (camel != type) jQuery(node).on(camel, jQueryHandler); 207 | } 208 | else { 209 | node.addEventListener(type, handler, false); 210 | if (camel != type) node.addEventListener(camel, handler, false); 211 | } 212 | } 213 | 214 | function randomString() { 215 | return Math.random().toString(36).slice(2) 216 | } 217 | 218 | function Property(value, onSubscribed) { 219 | const subscribers = new Map() 220 | let count = 0 221 | let keygen = 0 222 | this.get = function() { 223 | return value; 224 | }; 225 | this.set = function(newValue) { 226 | if (newValue !== value) { 227 | value = newValue; 228 | publish(); 229 | } 230 | }; 231 | this.subscribe = function(subscriber) { 232 | if (!count && onSubscribed) onSubscribed(true); 233 | subscribers.set(++keygen, subscriber) 234 | count++; 235 | return keygen; 236 | }; 237 | this.unsubscribe = function(key) { 238 | if (!subscribers.delete(key)) throw new Error("Not subscribed") 239 | count--; 240 | if (!count && onSubscribed) onSubscribed(false); 241 | }; 242 | this.publish = publish; 243 | function publish() { 244 | for (const subscriber of subscribers.values()) subscriber() 245 | }; 246 | if (typeof rxjs != 'undefined') { 247 | this.value$ = rxjs.fromEventPattern( 248 | h => this.subscribe(() => h(this.get())), 249 | (h, k) => this.unsubscribe(k) 250 | ).pipe( 251 | rxjs.startWith(this.get()) 252 | ) 253 | } 254 | } 255 | 256 | function extend(data) { 257 | return {__extends__: data}; 258 | } 259 | 260 | function getPropExt(obj, name) { 261 | if (obj[propPrefix+name]) return obj[propPrefix+name]; 262 | else if (name in obj) return convertToProperty(obj, name); 263 | else if (obj.__extends__) return getPropExt(obj.__extends__, name); 264 | else return null; 265 | } 266 | 267 | function getProp(obj, name) { 268 | return obj[propPrefix+name] || convertToProperty(obj, name); 269 | } 270 | 271 | function setProp(obj, name, prop) { 272 | if (prop instanceof Property) { 273 | Object.defineProperty(obj, propPrefix+name, { 274 | value: prop, 275 | writable: false, 276 | enumerable: false, 277 | configurable: false 278 | }) 279 | Object.defineProperty(obj, name, { 280 | get: prop.get, 281 | set: prop.set, 282 | enumerable: true, 283 | configurable: false 284 | }) 285 | } else { 286 | throw new Error("Not a Property object") 287 | } 288 | } 289 | 290 | function convertToProperty(obj, name) { 291 | const prop = new Property(obj[name]) 292 | Object.defineProperty(obj, propPrefix+name, { 293 | value: prop, 294 | writable: false, 295 | enumerable: false, 296 | configurable: false 297 | }) 298 | if (obj instanceof Array) { 299 | observeArray(obj); 300 | const isArrayIndex = regex.allDigits.test(name) 301 | if (!isArrayIndex || name < obj.length) { 302 | const desc = Object.getOwnPropertyDescriptor(obj, name) 303 | if (!desc || desc.configurable) { 304 | Object.defineProperty(obj, name, { 305 | get: prop.get, 306 | set: prop.set, 307 | enumerable: true, 308 | configurable: isArrayIndex 309 | }) 310 | } else { 311 | if (name !== "length") { 312 | console.warn("Object", obj, "property '" + name + "' is not configurable, change may not be detected") 313 | } 314 | prop.get = fallbackGet; 315 | prop.set = fallbackSet; 316 | } 317 | } 318 | } 319 | else { 320 | const desc = Object.getOwnPropertyDescriptor(obj, name) 321 | if (!desc || desc.configurable) { 322 | Object.defineProperty(obj, name, { 323 | get: prop.get, 324 | set: prop.set, 325 | enumerable: true, 326 | configurable: false 327 | }) 328 | } else { 329 | console.warn("Object", obj, "property '" + name + "' is not configurable, change may not be detected") 330 | prop.get = fallbackGet; 331 | prop.set = fallbackSet; 332 | } 333 | } 334 | function fallbackGet() { 335 | return obj[name]; 336 | } 337 | function fallbackSet(newValue) { 338 | if (newValue !== obj[name]) { 339 | obj[name] = newValue; 340 | prop.publish(); 341 | } 342 | } 343 | return prop; 344 | } 345 | 346 | function observeArray(arr) { 347 | if (arr.alter) return; 348 | arr.alter = alterArray; 349 | arr.push = proxy(arr.alter, arr, arr.push); 350 | arr.pop = proxy(arr.alter, arr, arr.pop); 351 | arr.shift = proxy(arr.alter, arr, arr.shift); 352 | arr.unshift = proxy(arr.alter, arr, arr.unshift); 353 | arr.splice = proxy(arr.alter, arr, arr.splice); 354 | arr.reverse = proxy(arr.alter, arr, arr.reverse); 355 | arr.sort = proxy(arr.alter, arr, arr.sort); 356 | if (arr.fill) arr.fill = proxy(arr.alter, arr, arr.fill); 357 | } 358 | 359 | function alterArray(func) { 360 | const len = this.length 361 | const val = func.apply(this, Array.prototype.slice.call(arguments, 1)) 362 | if (len != this.length) { 363 | const prop = this[propPrefix+"length"] 364 | if (prop) prop.publish(); 365 | } 366 | for (let i=len; i { 396 | if (bindingSrc.charAt(0) == "'" || bindingSrc.charAt(0) == '"') { 397 | strings.push(bindingSrc.slice(1, -1)); 398 | return "strings[" + (strings.length-1) + "]"; 399 | } 400 | else if (operator) { 401 | if (operator.slice(-1) == "(") { 402 | parts.push({bindingSrc: bindingSrc.slice(1, -operator.length)}); 403 | return "(parts[" + (parts.length-1) + "].get() || noOp)" + operator; 404 | } 405 | else { 406 | parts.push({bindingSrc: bindingSrc.slice(1, -operator.length), operator: operator}); 407 | return "parts[" + (parts.length-1) + "].value" + operator; 408 | } 409 | } 410 | else if (pmap[bindingSrc]) { 411 | return pmap[bindingSrc] 412 | } 413 | else { 414 | parts.push({bindingSrc: bindingSrc.slice(1)}); 415 | return pmap[bindingSrc] = "parts[" + (parts.length-1) + "].get()"; 416 | } 417 | }); 418 | const isSinglePart = regex.singlePart.test(expr) 419 | if (!regex.nonExpr.test(expr)) expr = "return " + expr 420 | expr = "const thisElem = scope.thisElem, event = scope.event;\n" + expr; 421 | let func 422 | try { 423 | func = new Function("noOp", "scope", "strings", "parts", expr); 424 | } catch (err) { 425 | printDebug(debugInfo); 426 | throw err; 427 | } 428 | return { funcs, strings, parts, isSinglePart, func } 429 | } 430 | 431 | function evalExpr(str, data, context, scope, debugInfo) { 432 | debugInfo = debugInfo.concat(prefix + str + suffix); 433 | const c = exprCache[str] || (exprCache[str] = parseExpr(str, debugInfo)) 434 | for (const func of c.funcs) { 435 | if (context[func] === undefined) { 436 | printDebug(debugInfo); 437 | throw new Error("Method '" + func + "' not found") 438 | } 439 | } 440 | const parts = [] 441 | for (const {bindingSrc, operator} of c.parts) { 442 | const prop = evalBindingSrc(bindingSrc, data, context, scope, debugInfo) 443 | if (operator) { 444 | const part = {subscribe: noOp, unsubscribe: noOp} 445 | Object.defineProperty(part, "value", { 446 | get: prop.get, 447 | set: prop.set, 448 | enumerable: true, 449 | configurable: false 450 | }) 451 | parts.push(part); 452 | } else { 453 | parts.push(prop) 454 | } 455 | } 456 | if (c.isSinglePart) return parts[0]; 457 | 458 | const keys = new Array(parts.length) 459 | const prop = new Property(null, subscribed => { 460 | if (subscribed) { 461 | for (let i=0; i { 496 | if (subscribed) { 497 | for (let i=0; i { 506 | const val = parts[i++].get() 507 | return val != null ? String(val) : ""; 508 | }); 509 | }; 510 | 511 | function subscribePart(part, i) { 512 | keys[i] = part.subscribe(prop.publish); 513 | } 514 | function unsubscribePart(part, i) { 515 | part.unsubscribe(keys[i]); 516 | } 517 | return prop; 518 | } 519 | 520 | function evalBindingSrc(str, data, context, scope, debugInfo) { 521 | const path = ("." + str).match(regex.propertyAccessor) 522 | const derefs = new Array(path.length) 523 | for (let i=0; i { 543 | isSubscribed = subscribed; 544 | if (subscribed) { 545 | buildParts(); 546 | for (let i=0; i 1) { 568 | ctx = parts[parts.length-2]; 569 | if (ctx instanceof Property) ctx = ctx.get(); 570 | } 571 | return function() { 572 | return curVal.apply(ctx, arguments); 573 | }; 574 | } else { 575 | return curVal 576 | } 577 | }; 578 | 579 | function evalPart(i) { 580 | const val = parts[i-1] instanceof Property ? parts[i-1].get() : parts[i-1] 581 | if (val instanceof Object) { 582 | const deref = derefs[i] instanceof Property ? derefs[i].get() : derefs[i] 583 | return getProp(val, deref); 584 | } 585 | else if (typeof val === "string") { 586 | const deref = derefs[i] instanceof Property ? derefs[i].get() : derefs[i] 587 | return val[deref]; 588 | } 589 | else { 590 | return undefined 591 | } 592 | } 593 | function buildParts() { 594 | for (let i=1; i { 623 | if (rebuildPartsFrom(i+1)) prop.publish(); 624 | }); 625 | } 626 | } 627 | function subscribeDeref(deref, i) { 628 | if (deref instanceof Property) { 629 | derefKeys[i] = deref.subscribe(() => { 630 | if (rebuildPartsFrom(i)) prop.publish(); 631 | }); 632 | } 633 | } 634 | function unsubscribePart(part, i) { 635 | if (part instanceof Property) 636 | part.unsubscribe(keys[i]); 637 | } 638 | function unsubscribeDeref(deref, i) { 639 | if (deref instanceof Property) 640 | deref.unsubscribe(derefKeys[i]); 641 | } 642 | return prop; 643 | } 644 | 645 | /** 646 | * Binding 647 | */ 648 | function makeBinding(prop, priority, onChange, onUnbind) { 649 | let subkey = null 650 | function notifyChange() { 651 | if (subkey) onChange() 652 | } 653 | return { 654 | bind() { 655 | if (subkey) throw new Error("Already bound"); 656 | subkey = prop.subscribe(() => callLater(notifyChange, priority)) 657 | onChange() 658 | }, 659 | unbind() { 660 | if (subkey) { 661 | prop.unsubscribe(subkey); 662 | subkey = null; 663 | if (onUnbind) onUnbind() 664 | } 665 | }, 666 | isBound() { 667 | return Boolean(subkey); 668 | } 669 | } 670 | } 671 | 672 | function makeBindingStore() { 673 | const bindings = [] 674 | return { 675 | add(b) { 676 | bindings.push(b) 677 | }, 678 | unbind() { 679 | for (const b of bindings) b.unbind() 680 | }, 681 | rebind() { 682 | for (const b of bindings) b.bind() 683 | } 684 | } 685 | } 686 | 687 | function makeRepeater(name, node, data, context, debugInfo, depth) { 688 | const isReverse = node.hasAttribute("data-reverse") 689 | if (isReverse) node.removeAttribute("data-reverse"); 690 | const parent = node.parentNode 691 | const tail = isReverse ? node.previousSibling : node.nextSibling 692 | parent.removeChild(node); 693 | let count = 0 694 | const bindingStores = [] 695 | const cache = document.createDocumentFragment() 696 | let cacheTimeout = null 697 | 698 | return { 699 | update(newCount) { 700 | newCount = Number(newCount); 701 | if (isNaN(newCount) || newCount < 0) newCount = 0; 702 | if (newCount > count) { 703 | const newElems = document.createDocumentFragment() 704 | for (let i=count; i=newCount; i--) { 730 | const prevElem = isReverse ? elem.nextSibling : elem.previousSibling 731 | bindingStores[i].unbind(); 732 | cache.insertBefore(elem, cache.firstChild); 733 | elem = prevElem; 734 | } 735 | } 736 | count = newCount; 737 | if (cacheTimeout) { 738 | timer.cancel(cacheTimeout); 739 | cacheTimeout = null; 740 | } 741 | if (cache.firstChild && api.repeaterCacheTTL) { 742 | cacheTimeout = timer.callAfter(api.repeaterCacheTTL, clearCache); 743 | } 744 | } 745 | } 746 | 747 | function clearCache() { 748 | while (cache.lastChild) { 749 | bindingStores.pop(); 750 | if (window.jQuery) jQuery(cache.lastChild).remove(); 751 | else cache.removeChild(cache.lastChild); 752 | } 753 | } 754 | } 755 | 756 | function dataBind(node, data, context, bindingStore, debugInfo, depth) { 757 | if (node.nodeType == 1 && !["SCRIPT", "STYLE", "TEMPLATE"].includes(node.tagName)) { 758 | if (api.onDataBinding) api.onDataBinding(node); 759 | let dirs = getDirectives(node) 760 | if (dirs.repeater) { 761 | removeDirectives(node, dirs); 762 | const repeater = makeRepeater(dirs.repeater.name, node, data, context, debugInfo, depth + 1) 763 | let expr 764 | if (dirs.repeater.view 765 | && !(context.slots && context.slots[dirs.repeater.view]) 766 | && !api.views[dirs.repeater.view] 767 | ) { 768 | if (/^\d/.test(dirs.repeater.view)) { 769 | printDebug(debugInfo) 770 | throw new Error('Missing slot ' + dirs.repeater.view) 771 | } 772 | unreadyViews.add(dirs.repeater.view) 773 | const name = randomString() 774 | setProp(data, name, getProp(api.views, dirs.repeater.view)) 775 | expr = "!#" + name + " ? 0 : " + dirs.repeater.value 776 | } else { 777 | expr = dirs.repeater.value 778 | } 779 | const prop = evalExpr(expr, data, context, {}, debugInfo) 780 | const binding = makeBinding(prop, depth, () => repeater.update(prop.get()), () => repeater.update(0)) 781 | binding.bind(); 782 | bindingStore.add(binding) 783 | } 784 | else { 785 | while (dirs.view) { 786 | const viewName = dirs.view 787 | const view = context.slots && context.slots[viewName] || api.views[viewName] 788 | if (!view) { 789 | if (/^\d/.test(viewName)) { 790 | printDebug(debugInfo) 791 | throw new Error('Missing slot ' + viewName) 792 | } 793 | unreadyViews.add(viewName) 794 | const repeater = makeRepeater(null, node, data, context, debugInfo, depth + 1) 795 | const prop = getProp(api.views, viewName) 796 | const binding = makeBinding(prop, depth, () => repeater.update(prop.get() ? 1 : 0), () => repeater.update(0)) 797 | binding.bind(); 798 | bindingStore.add(binding) 799 | return; 800 | } 801 | const slots = [...node.children].map(child => ({template: child, controller: noOp})) 802 | const newNode = view.template.cloneNode(true) 803 | if (node.className) { 804 | newNode.className = newNode.className ? (newNode.className + " " + node.className) : node.className 805 | } 806 | for (let i=0; i { 882 | const textarea = document.createElement("textarea") 883 | textarea.innerHTML = prop.get(); 884 | node.nodeValue = textarea.value; 885 | }) 886 | binding.bind(); 887 | bindingStore.add(binding) 888 | } 889 | } 890 | } 891 | 892 | function bindParam(data, paramName, prop, bindingStore, depth) { 893 | if (prop.isExpr) { 894 | const binding = makeBinding(prop, depth, () => data[paramName] = prop.get()) 895 | binding.bind(); 896 | bindingStore.add(binding) 897 | } else { 898 | setProp(data, paramName, prop) 899 | } 900 | } 901 | 902 | /** 903 | * API 904 | */ 905 | const api = { 906 | directives: { //you can change the names of the binding directives by modifying this object 907 | bindView: "bind-view", 908 | bindParameter: "bind-param-", 909 | bindVariable: "bind-var-", 910 | bindStatement: "bind-statement-", 911 | bindEvent: "bind-event-", 912 | bindRepeater: "bind-repeater-" 913 | }, 914 | views: {}, //declare your views, name->value where value={template: anHtmlTemplate, controller: function(rootElementOfView)} 915 | onDataBinding: null, //set this to a function that will be called before each node is bound, you can use this to process custom directives 916 | autoBind: true, //if true, automatically dataBind the entire document as soon as it is ready 917 | repeaterCacheTTL: 300000, //removed repeater items are kept in a cache for reuse, the cache is cleared if it is not accessed within the TTL 918 | timerInterval: 30000, //granularity of the internal timer 919 | 920 | /** 921 | * Process a binding expression and return a _proxy_ representing its value 922 | * @param {string} str A binding expression 923 | * @param {object} data Binding sources 924 | * @param {object} context Value of `this` 925 | * @param {object} scope Local variables 926 | * @param {array} debugInfo Debug info to accompany error messages 927 | * @returns {PropertyProxy} A proxy object with methods for change subscription 928 | */ 929 | evalExpr(str, data, context = {}, scope = {}, debugInfo = []) { 930 | return evalExpr(str, data, context, scope, debugInfo) 931 | }, 932 | 933 | /** 934 | * Process a string containing zero or more binding expressions enclosed in double brackets, 935 | * return a _proxy_ representing its interpolated value 936 | * @param {string} str String containing binding expressions 937 | * @param {object} data Binding sources 938 | * @param {object} context Value of `this` 939 | * @param {object} scope Local variables 940 | * @param {array} debugInfo Debug info to accompany error messages 941 | * @returns {PropertyProxy} A proxy object with methods for change subscription 942 | */ 943 | evalText(str, data, context = {}, scope = {}, debugInfo = []) { 944 | return evalText(str, data, context, scope, debugInfo) 945 | }, 946 | 947 | /** 948 | * Get the proxy for the specified object's property, which has methods for mutation and change subscription 949 | * @param {object} obj An object 950 | * @param {string} prop The property name 951 | * @returns {PropertyProxy} The proxy 952 | */ 953 | getPropertyProxy(obj, prop) { 954 | return getProp(obj, prop) 955 | }, 956 | 957 | /** 958 | * Set the given proxy as the _backing_ for the specified object's property. 959 | * This allows binding this property's value to the value of another object's property (using `getPropertyProxy`), 960 | * or to the dynamic value of an expression (using `evalExpr` or `evalText`) 961 | * @param {object} obj An object 962 | * @param {string} prop The property name 963 | * @param {PropertyProxy} proxy The proxy 964 | */ 965 | setPropertyProxy(obj, prop, proxy) { 966 | return setProp(obj, prop, proxy) 967 | }, 968 | 969 | /** 970 | * Process binding directives on a DOM tree 971 | * @param {HTMLElement} elem The root DOM element (the View) 972 | * @param {object} context An object that acts as both ViewModel (whose properties can be bound with `#`) and ViewController (whose methods can be invoked via `this`) 973 | * @param {array} debugInfo The element's path, included in console error logs 974 | * @returns A binding-store with two methods `unbind()` and `rebind()` 975 | */ 976 | dataBind(elem, context, debugInfo) { 977 | const bindingStore = makeBindingStore() 978 | dataBind(elem, context, context, bindingStore, debugInfo||[], 0) 979 | return bindingStore 980 | }, 981 | 982 | getMissingViews() { 983 | return [...unreadyViews].filter(x => !api.views[x]) 984 | }, 985 | }; 986 | 987 | function onReady() { 988 | if (api.autoBind) { 989 | console.log("Auto binding document, to disable auto binding set dataBinder.autoBind to false") 990 | const startTime = Date.now() 991 | api.dataBind(document.body, window, null, ["document"]); 992 | console.log("Finished binding document", Date.now()-startTime, "ms") 993 | setTimeout(() => { 994 | const missing = api.getMissingViews() 995 | if (missing.length) console.warn("Missing views", missing) 996 | }, 3000) 997 | } 998 | } 999 | 1000 | if (!window.dataBinder) { 1001 | window.dataBinder = api; 1002 | if (window.jQuery) jQuery(onReady); 1003 | else document.addEventListener("DOMContentLoaded", onReady, false); 1004 | } 1005 | })(); 1006 | --------------------------------------------------------------------------------