((root, factory) ->
if typeof root.define == 'function' && root.define.amd root.define([], factory) else if typeof module == 'object' && module.exports module.exports = factory() else root.Twine = factory()
)(this, ->
Twine = {} Twine.shouldDiscardEvent = {} # Map of node binding ids to objects that describe a node's bindings. elements = {} # Registered components to look up registry = {} # The number of nodes bound since the last call to Twine.reset(). # Used to determine the next binding id. nodeCount = 0 # Storage for all bindable data, provided by the caller of Twine.reset(). rootContext = null keypathRegex = /^[a-z]\w*(\.[a-z]\w*|\[\d+\])*$/i # Tests if a string is a pure keypath. refreshQueued = false refreshCallbacks = [] rootNode = null currentBindingCallbacks = null Twine.getAttribute = (node, attr) -> node.getAttribute("data-#{attr}") || node.getAttribute(attr) # Cleans up all existing bindings and sets the root node and context. Twine.reset = (newContext, node = document.documentElement) -> for key of elements if bindings = elements[key]?.bindings obj.teardown() for obj in bindings when obj.teardown elements = {} rootContext = newContext rootNode = node rootNode.bindingId = nodeCount = 1 this Twine.bind = (node = rootNode, context = Twine.context(node)) -> bind(context, node, getIndexesForElement(node), true) Twine.afterBound = (callback) -> if currentBindingCallbacks currentBindingCallbacks.push(callback) else callback() bind = (context, node, indexes, forceSaveContext) -> currentBindingCallbacks = [] element = null if node.bindingId Twine.unbind(node) if defineArrayAttr = Twine.getAttribute(node, 'define-array') newIndexes = defineArray(node, context, defineArrayAttr) indexes ?= {} for key, value of indexes when !newIndexes.hasOwnProperty(key) newIndexes[key] = value indexes = newIndexes # register the element early because subsequent bindings on the same node might need to make use of the index element = findOrCreateElementForNode(node) element.indexes = indexes bindingConstructors = null for attribute in node.attributes type = attribute.name type = type.slice(5) if isDataAttribute(type) constructor = Twine.bindingTypes[type] continue unless constructor bindingConstructors ?= [] definition = attribute.value bindingConstructors.push([type, constructor, definition]) if bindingConstructors element ?= findOrCreateElementForNode(node) element.bindings ?= [] element.indexes ?= indexes for [_, constructor, definition] in bindingConstructors.sort(bindingOrder) binding = constructor(node, context, definition, element) element.bindings.push(binding) if binding if newContextKey = Twine.getAttribute(node, 'context') keypath = keypathForKey(node, newContextKey) if keypath[0] == '$root' context = rootContext keypath = keypath.slice(1) context = getValue(context, keypath) || setValue(context, keypath, {}) if element || newContextKey || forceSaveContext element ?= findOrCreateElementForNode(node) element.childContext = context element.indexes ?= indexes if indexes? callbacks = currentBindingCallbacks # IE and Safari don't support node.children for DocumentFragment or SVGElement, # See explanation in childrenForNode() bind(context, childNode, if newContextKey? then null else indexes) for childNode in childrenForNode(node) Twine.count = nodeCount for callback in callbacks || [] callback() currentBindingCallbacks = null Twine # IE and Safari don't support node.children for DocumentFragment and SVGElement nodes. # If the element supports children we continue to traverse the children, otherwise # we stop traversing that subtree. # https://developer.mozilla.org/en-US/docs/Web/API/ParentNode.children # As a result, Twine are unsupported within DocumentFragment and SVGElement nodes. # # We also prevent nodes from being iterated over more than once by cacheing the # lookup for children nodes, which prevents nodes that are dynamically inserted # or removed as siblings from causing double/ missed binds and unbinds. childrenForNode = (node) -> if node.children then Array::slice.call(node.children, 0) else [] findOrCreateElementForNode = (node) -> node.bindingId ?= ++nodeCount elements[node.bindingId] ?= {} # Queues a refresh of the DOM, batching up calls for the current synchronous block. # The callback will be called once when the refresh has completed. Twine.refresh = (callback) -> refreshCallbacks.push(callback) if callback return if refreshQueued refreshQueued = true setTimeout(Twine.refreshImmediately, 0) refreshElement = (element) -> (obj.refresh() if obj.refresh?) for obj in element.bindings if element.bindings return Twine.refreshImmediately = -> refreshQueued = false refreshElement(element) for key, element of elements callbacks = refreshCallbacks refreshCallbacks = [] cb() for cb in callbacks return Twine.register = (name, component) -> if registry[name] throw new Error("Twine error: '#{name}' is already registered with Twine") else registry[name] = component # Force the binding system to recognize programmatic changes to a node's value. Twine.change = (node, bubble = false) -> event = document.createEvent("HTMLEvents") event.initEvent('change', bubble, true) # for IE 9/10 compatibility. node.dispatchEvent(event) # Cleans up everything related to a node and its subtree. Twine.unbind = (node) -> if id = node.bindingId if bindings = elements[id]?.bindings obj.teardown() for obj in bindings when obj.teardown delete elements[id] delete node.bindingId # IE and Safari don't support node.children for DocumentFragment or SVGElement, # See explanation in childrenForNode() Twine.unbind(childNode) for childNode in childrenForNode(node) this # Returns the binding context for a node by looking up the tree. Twine.context = (node) -> getContext(node, false) Twine.childContext = (node) -> getContext(node, true) getContext = (node, child) -> while node return rootContext if node == rootNode node = node.parentNode if !child if !node console.warn "Unable to find context; please check that the node is attached to the DOM that Twine has bound, or that bindings have been initiated on this node's DOM" return null if (id = node.bindingId) && (context = elements[id]?.childContext) return context node = node.parentNode if child getIndexesForElement = (node) -> firstContext = null while node return elements[id]?.indexes if id = node.bindingId node = node.parentNode # Returns the fully qualified key for a node's context Twine.contextKey = (node, lastContext) -> keys = [] addKey = (context) -> for key, val of context when lastContext == val keys.unshift(key) break lastContext = context while node && node != rootNode && node = node.parentNode addKey(context) if (id = node.bindingId) && (context = elements[id]?.childContext) addKey(rootContext) if node == rootNode keys.join('.') valuePropertyForNode = (node) -> name = node.nodeName.toLowerCase() if name in ['input', 'textarea', 'select'] if node.getAttribute('type') in ['checkbox', 'radio'] then 'checked' else 'value' else 'textContent' keypathForKey = (node, key) -> keypath = [] for key, i in key.split('.') if (start = key.indexOf('[')) != -1 if i == 0 keypath.push(keyWithArrayIndex(key.substr(0, start), node)...) else keypath.push(key.substr(0, start)) key = key.substr(start) while (end = key.indexOf(']')) != -1 keypath.push(parseInt(key.substr(1, end), 10)) key = key.substr(end + 1) else if i == 0 keypath.push(keyWithArrayIndex(key, node)...) else keypath.push(key) keypath keyWithArrayIndex = (key, node) -> index = elements[node.bindingId]?.indexes?[key] if index? [key, index] else [key] getValue = (object, keypath) -> object = object[key] for key in keypath when object? object setValue = (object, keypath, value) -> [keypath..., lastKey] = keypath for key in keypath object = object[key] ?= {} object[lastKey] = value stringifyNodeAttributes = (node) -> [].map.call(node.attributes, (attr) -> "#{attr.name}=#{JSON.stringify(attr.value)}").join(' ') wrapFunctionString = (code, args, node) -> if isKeypath(code) && keypath = keypathForKey(node, code) if keypath[0] == '$root' ($context, $root) -> getValue($root, keypath) else ($context, $root) -> getValue($context, keypath) else code = "return #{code}" code = "with($arrayPointers) { #{code} }" if nodeArrayIndexes(node) code = "with($registry) { #{code} }" if requiresRegistry(args) try new Function(args, "with($context) { #{code} }") catch e throw "Twine error: Unable to create function on #{node.nodeName} node with attributes #{stringifyNodeAttributes(node)}" requiresRegistry = (args) -> /\$registry/.test(args) nodeArrayIndexes = (node) -> node.bindingId? && elements[node.bindingId]?.indexes arrayPointersForNode = (node, context) -> indexes = nodeArrayIndexes(node) return {} unless indexes result = {} for key, index of indexes result[key] = context[key][index] result isKeypath = (value) -> value not in ['true', 'false', 'null', 'undefined'] && keypathRegex.test(value) isDataAttribute = (value) -> value[0] == 'd' && value[1] == 'a' && value[2] == 't' && value[3] == 'a' && value[4] == '-' fireCustomChangeEvent = (node) -> event = document.createEvent('CustomEvent') event.initCustomEvent('bindings:change', true, false, {}) node.dispatchEvent(event) bindingOrder = ([firstType], [secondType]) -> ORDERED_BINDINGS = { define: 1, bind: 2, eval: 3 } return 1 unless ORDERED_BINDINGS[firstType] return -1 unless ORDERED_BINDINGS[secondType] ORDERED_BINDINGS[firstType] - ORDERED_BINDINGS[secondType] Twine.bindingTypes = bind: (node, context, definition) -> valueProp = valuePropertyForNode(node) value = node[valueProp] lastValue = undefined teardown = undefined # Radio buttons only set the value to the node value if checked. checkedValueType = node.getAttribute('type') == 'radio' fn = wrapFunctionString(definition, '$context,$root,$arrayPointers', node) refresh = -> newValue = fn.call(node, context, rootContext, arrayPointersForNode(node, context)) return if newValue == lastValue # return if we can and avoid a DOM operation lastValue = newValue return if newValue == node[valueProp] node[valueProp] = if checkedValueType then newValue == node.value else newValue fireCustomChangeEvent(node) return {refresh} unless isKeypath(definition) refreshContext = -> if checkedValueType return unless node.checked setValue(context, keypath, node.value) else setValue(context, keypath, node[valueProp]) keypath = keypathForKey(node, definition) twoWayBinding = valueProp != 'textContent' && node.type != 'hidden' if keypath[0] == '$root' context = rootContext keypath = keypath.slice(1) if value? && (twoWayBinding || value != '') && !(oldValue = getValue(context, keypath))? refreshContext() if twoWayBinding events = ['input', 'keyup', 'change'] changeHandler = -> return if getValue(context, keypath) == this[valueProp] refreshContext() Twine.refreshImmediately() node.addEventListener(eventName, changeHandler) for eventName in events teardown = -> node.removeEventListener(eventName, changeHandler) for eventName in events {refresh, teardown} 'bind-show': (node, context, definition) -> fn = wrapFunctionString(definition, '$context,$root,$arrayPointers', node) lastValue = undefined return refresh: -> newValue = !fn.call(node, context, rootContext, arrayPointersForNode(node, context)) return if newValue == lastValue node.classList.toggle('hide', lastValue = newValue) 'bind-class': (node, context, definition) -> fn = wrapFunctionString(definition, '$context,$root,$arrayPointers', node) lastValues = {} return refresh: -> newValues = fn.call(node, context, rootContext, arrayPointersForNode(node, context)) for key, value of newValues newValue = newValues[key] = !!newValues[key] currValue = lastValues[key] ? node.classList.contains(key) if currValue != newValue if newValue node.classList.add(key) else node.classList.remove(key) lastValues = newValues 'bind-attribute': (node, context, definition) -> fn = wrapFunctionString(definition, '$context,$root,$arrayPointers', node) lastValue = {} return refresh: -> newValue = fn.call(node, context, rootContext, arrayPointersForNode(node, context)) for key, value of newValue when lastValue[key] != value if (!value) node.removeAttribute(key) else node.setAttribute(key, value) lastValue = newValue define: (node, context, definition) -> fn = wrapFunctionString(definition, '$context,$root,$registry,$arrayPointers', node) object = fn.call(node, context, rootContext, registry, arrayPointersForNode(node, context)) context[key] = value for key, value of object return eval: (node, context, definition) -> fn = wrapFunctionString(definition, '$context,$root,$registry,$arrayPointers', node) fn.call(node, context, rootContext, registry, arrayPointersForNode(node, context)) return defineArray = (node, context, definition) -> fn = wrapFunctionString(definition, '$context,$root', node) object = fn.call(node, context, rootContext) indexes = {} for key, value of object context[key] ?= [] throw "Twine error: expected '#{key}' to be an array" unless context[key] instanceof Array indexes[key] = context[key].length context[key].push(value) indexes setupPropertyBinding = (attributeName, bindingName) -> booleanProp = attributeName in ['checked', 'indeterminate', 'disabled', 'readOnly', 'draggable'] Twine.bindingTypes["bind-#{bindingName.toLowerCase()}"] = (node, context, definition) -> fn = wrapFunctionString(definition, '$context,$root,$arrayPointers', node) lastValue = undefined return refresh: -> newValue = fn.call(node, context, rootContext, arrayPointersForNode(node, context)) newValue = !!newValue if booleanProp return if newValue == lastValue node[attributeName] = lastValue = newValue fireCustomChangeEvent(node) if attributeName == 'checked' for attribute in ['placeholder', 'checked', 'indeterminate', 'disabled', 'href', 'title', 'readOnly', 'src', 'draggable'] setupPropertyBinding(attribute, attribute) setupPropertyBinding('innerHTML', 'unsafe-html') preventDefaultForEvent = (event) -> (event.type == 'submit' || event.currentTarget.nodeName.toLowerCase() == 'a') && Twine.getAttribute(event.currentTarget, 'allow-default') in ['false', false, 0, undefined, null] setupEventBinding = (eventName) -> Twine.bindingTypes["bind-event-#{eventName}"] = (node, context, definition) -> onEventHandler = (event, data = event.detail) -> discardEvent = Twine.shouldDiscardEvent[eventName]?(event) if discardEvent || preventDefaultForEvent(event) event.preventDefault() return if discardEvent wrapFunctionString(definition, '$context,$root,$arrayPointers,event,data', node).call(node, context, rootContext, arrayPointersForNode(node, context), event, data) Twine.refreshImmediately() node.addEventListener(eventName, onEventHandler) return teardown: -> node.removeEventListener(eventName, onEventHandler) for eventName in ['click', 'dblclick', 'mouseenter', 'mouseleave', 'mouseover', 'mouseout', 'mousedown', 'mouseup', 'submit', 'dragenter', 'dragleave', 'dragover', 'drop', 'drag', 'change', 'keypress', 'keydown', 'keyup', 'input', 'error', 'done', 'success', 'fail', 'blur', 'focus', 'load', 'paste'] setupEventBinding(eventName) Twine
)