batman.coffee | |
---|---|
batman.js Created by Nick Small Copyright 2011, Shopify | |
The global namespace, the | Batman = (mixins...) ->
new Batman.Object mixins... |
Global Helpers | |
| Batman.typeOf = $typeOf = (object) ->
return "Undefined" if typeof object == 'undefined'
_objectToString.call(object).slice(8, -1) |
Cache this function to skip property lookups. | _objectToString = Object.prototype.toString |
| Batman.mixin = $mixin = (to, mixins...) ->
hasSet = typeof to.set is 'function'
for mixin in mixins
continue if $typeOf(mixin) isnt 'Object'
for own key, value of mixin
continue if key in ['initialize', 'uninitialize', 'prototype']
if hasSet
to.set(key, value)
else if to.nodeName?
Batman.data to, key, value
else
to[key] = value
if typeof mixin.initialize is 'function'
mixin.initialize.call to
to |
| Batman.unmixin = $unmixin = (from, mixins...) ->
for mixin in mixins
for key of mixin
continue if key in ['initialize', 'uninitialize']
delete from[key]
if typeof mixin.uninitialize is 'function'
mixin.uninitialize.call from
from |
We can use $block to make it accept the callback in both ways:
or | Batman._block = $block = (lengthOrFunction, fn) ->
if fn?
argsLength = lengthOrFunction
else
fn = lengthOrFunction
callbackEater = (args...) ->
ctx = @
f = (callback) ->
args.push callback
fn.apply(ctx, args) |
Call the function right now if we've been passed the callback already or if we've reached the argument count threshold | if (typeof args[args.length-1] is 'function') || (argsLength && (args.length >= argsLength))
f(args.pop())
else
f |
| Batman._findName = $findName = (f, context) ->
unless f.displayName
for key, value of context
if value is f
f.displayName = key
break
f.displayName |
| Batman._functionName = $functionName = (f) ->
return f.__name__ if f.__name__
return f.name if f.name
f.toString().match(/\W*function\s+([\w\$]+)\(/)?[1] |
| Batman._preventDefault = $preventDefault = (e) ->
if typeof e.preventDefault is "function" then e.preventDefault() else e.returnValue = false
Batman._isChildOf = $isChildOf = (parentNode, childNode) ->
node = childNode.parentNode
while node
return true if node == parentNode
node = node.parentNode
false |
| Batman.translate = (x, values = {}) -> helpers.interpolate($get(Batman.translate.messages, x), values)
Batman.translate.messages = {}
t = -> Batman.translate(arguments...) |
Developer Tooling | developer =
suppressed: false
DevelopmentError: (->
DevelopmentError = (@message) ->
@name = "DevelopmentError"
DevelopmentError:: = Error::
DevelopmentError
)()
_ie_console: (f, args) ->
console?[f] "...#{f} of #{args.length} items..." unless args.length == 1
console?[f] arg for arg in args
suppress: (f) ->
developer.suppressed = true
if f
f()
developer.suppressed = false
unsuppress: ->
developer.suppressed = false
log: ->
return if developer.suppressed or !(console?.log?)
if console.log.apply then console.log(arguments...) else developer._ie_console "log", arguments
warn: ->
return if developer.suppressed or !(console?.warn?)
if console.warn.apply then console.warn(arguments...) else developer._ie_console "warn", arguments
error: (message) -> throw new developer.DevelopmentError(message)
assert: (result, message) -> developer.error(message) unless result
do: (f) -> f()
addFilters: ->
$mixin Batman.Filters,
log: (value, key) ->
console?.log? arguments
value
logStack: (value) ->
console?.log? developer.currentFilterStack
value
Batman.developer = developer |
Helpers | camelize_rx = /(?:^|_|\-)(.)/g
capitalize_rx = /(^|\s)([a-z])/g
underscore_rx1 = /([A-Z]+)([A-Z][a-z])/g
underscore_rx2 = /([a-z\d])([A-Z])/g |
Just a few random Rails-style string helpers. You can add more to the Batman.helpers object. | helpers = Batman.helpers = {
camelize: (string, firstLetterLower) ->
string = string.replace camelize_rx, (str, p1) -> p1.toUpperCase()
if firstLetterLower then string.substr(0,1).toLowerCase() + string.substr(1) else string
underscore: (string) ->
string.replace(underscore_rx1, '$1_$2')
.replace(underscore_rx2, '$1_$2')
.replace('-', '_').toLowerCase()
singularize: (string) ->
len = string.length
if string.substr(len - 3) is 'ies'
string.substr(0, len - 3) + 'y'
else if string.substr(len - 1) is 's'
string.substr(0, len - 1)
else
string
pluralize: (count, string) ->
if string
return string if count is 1
else
string = count
len = string.length
lastLetter = string.substr(len - 1)
if lastLetter is 'y'
"#{string.substr(0, len - 1)}ies"
else if lastLetter is 's'
string
else
"#{string}s"
capitalize: (string) -> string.replace capitalize_rx, (m,p1,p2) -> p1+p2.toUpperCase()
trim: (string) -> if string then string.trim() else ""
interpolate: (stringOrObject, keys) ->
if typeof stringOrObject is 'object'
string = stringOrObject[keys.count]
unless string
string = stringOrObject['other']
else
string = stringOrObject
for key, value of keys
string = string.replace(new RegExp("%\\{#{key}\\}", "g"), value)
string
}
class Batman.Event
@forBaseAndKey: (base, key) ->
if base.isEventEmitter
base.event(key)
else
new Batman.Event(base, key)
constructor: (@base, @key) ->
@handlers = new Batman.SimpleSet
@_preventCount = 0
isEvent: true
isEqual: (other) ->
@constructor is other.constructor and @base is other.base and @key is other.key
hashKey: ->
@hashKey = -> key
key = "<Batman.Event base: #{Batman.Hash::hashKeyFor(@base)}, key: \"#{Batman.Hash::hashKeyFor(@key)}\">"
addHandler: (handler) ->
@handlers.add(handler)
@autofireHandler(handler) if @oneShot
this
removeHandler: (handler) ->
@handlers.remove(handler)
this
eachHandler: (iterator) ->
@handlers.forEach(iterator)
if @base?.isEventEmitter
key = @key
@base._batman.ancestors (ancestor) ->
if ancestor.isEventEmitter
handlers = ancestor.event(key).handlers
handlers.forEach(iterator)
handlerContext: -> @base
prevent: -> ++@_preventCount
allow: ->
--@_preventCount if @_preventCount
@_preventCount
isPrevented: -> @_preventCount > 0
autofireHandler: (handler) ->
if @_oneShotFired and @_oneShotArgs?
handler.apply(@handlerContext(), @_oneShotArgs)
resetOneShot: ->
@_oneShotFired = false
@_oneShotArgs = null
fire: ->
return false if @isPrevented() or @_oneShotFired
context = @handlerContext()
args = arguments
if @oneShot
@_oneShotFired = true
@_oneShotArgs = arguments
@eachHandler (handler) -> handler.apply(context, args)
Batman.EventEmitter =
isEventEmitter: true
event: (key) ->
Batman.initializeObject @
eventClass = @eventClass or Batman.Event
events = @_batman.events ||= new Batman.SimpleHash
if existingEvent = events.get(key)
existingEvent
else
existingEvents = @_batman.get('events')
newEvent = events.set(key, new eventClass(this, key))
newEvent.oneShot = existingEvents?.get(key)?.oneShot
newEvent
on: (key, handler) ->
@event(key).addHandler(handler)
registerAsMutableSource: ->
Batman.Property.registerSource(@)
mutation: (wrappedFunction) ->
->
result = wrappedFunction.apply(this, arguments)
@event('change').fire(this, this)
result
prevent: (key) ->
@event(key).prevent()
@
allow: (key) ->
@event(key).allow()
@
isPrevented: (key) ->
@event(key).isPrevented()
fire: (key, args...) ->
@event(key).fire(args...)
class Batman.PropertyEvent extends Batman.Event
eachHandler: (iterator) -> @base.eachObserver(iterator)
handlerContext: -> @base.base
class Batman.Property
$mixin @prototype, Batman.EventEmitter
@_sourceTrackerStack: []
@sourceTracker: -> (stack = @_sourceTrackerStack)[stack.length - 1]
@defaultAccessor:
get: (key) -> @[key]
set: (key, val) -> @[key] = val
unset: (key) -> x = @[key]; delete @[key]; x
@forBaseAndKey: (base, key) ->
if base.isObservable
base.property(key)
else
new Batman.Keypath(base, key)
@registerSource: (obj) ->
return unless obj.isEventEmitter
@sourceTracker()?.add(obj)
constructor: (@base, @key) ->
_isolationCount: 0
cached: no
value: null
sources: null
isProperty: true
eventClass: Batman.PropertyEvent
isEqual: (other) ->
@constructor is other.constructor and @base is other.base and @key is other.key
hashKey: ->
@hashKey = -> key
key = "<Batman.Property base: #{Batman.Hash::hashKeyFor(@base)}, key: \"#{Batman.Hash::hashKeyFor(@key)}\">"
changeEvent: ->
event = @event('change')
@changeEvent = -> event
event
accessor: ->
keyAccessors = @base._batman?.get('keyAccessors')
accessor = if keyAccessors && (val = keyAccessors.get(@key))
val
else
@base._batman?.getFirst('defaultAccessor') or Batman.Property.defaultAccessor
@accessor = -> accessor
accessor
eachObserver: (iterator) ->
key = @key
@changeEvent().handlers.forEach(iterator)
if @base.isObservable
@base._batman.ancestors (ancestor) ->
if ancestor.isObservable
property = ancestor.property(key)
handlers = property.event('change').handlers
handlers.forEach(iterator)
pushSourceTracker: -> Batman.Property._sourceTrackerStack.push(new Batman.SimpleSet)
updateSourcesFromTracker: ->
newSources = Batman.Property._sourceTrackerStack.pop()
handler = @sourceChangeHandler()
@_eachSourceChangeEvent (e) -> e.removeHandler(handler)
@sources = newSources
@_eachSourceChangeEvent (e) -> e.addHandler(handler)
_eachSourceChangeEvent: (iterator) ->
return unless @sources?
@sources.forEach (source) -> iterator(source.event('change'))
getValue: ->
@registerAsMutableSource()
unless @cached
@pushSourceTracker()
@value = @valueFromAccessor()
@cached = yes
@updateSourcesFromTracker()
@value
refresh: ->
@cached = no
previousValue = @value
value = @getValue()
if value isnt previousValue and not @isIsolated()
@fire(value, previousValue)
sourceChangeHandler: ->
handler = => @_handleSourceChange()
@sourceChangeHandler = -> handler
handler
_handleSourceChange: ->
if @isIsolated()
@_needsRefresh = yes
else if @changeEvent().handlers.isEmpty()
@cached = no
else
@refresh()
valueFromAccessor: -> @accessor().get?.call(@base, @key)
setValue: (val) ->
@cached = no
result = @accessor().set?.call(@base, @key, val)
@refresh()
result
unsetValue: ->
result = @accessor().unset?.call(@base, @key)
@refresh()
result
forget: (handler) ->
if handler?
@changeEvent().removeHandler(handler)
else
@changeEvent().handlers.clear()
observeAndFire: (handler) ->
@observe(handler)
handler.call(@base, @value, @value)
observe: (handler) ->
@changeEvent().addHandler(handler)
@getValue()
this
fire: -> @changeEvent().fire(arguments...)
isolate: ->
if @_isolationCount is 0
@_preIsolationValue = @getValue()
@_isolationCount++
expose: ->
if @_isolationCount is 1
@_isolationCount--
if @_needsRefresh
@value = @_preIsolationValue
@refresh()
else if @value isnt @_preIsolationValue
@fire(@value, @_preIsolationValue)
@_preIsolationValue = null
else if @_isolationCount > 0
@_isolationCount--
isIsolated: -> @_isolationCount > 0 |
Keypaths | class Batman.Keypath extends Batman.Property
constructor: (base, key) ->
if $typeOf(key) is 'String'
@segments = key.split('.')
@depth = @segments.length
else
@segments = [key]
@depth = 1
super
slice: (begin, end=@depth) ->
base = @base
for segment in @segments.slice(0, begin)
return unless base? and base = Batman.Property.forBaseAndKey(base, segment).getValue()
Batman.Property.forBaseAndKey base, @segments.slice(begin, end).join('.')
terminalProperty: -> @slice -1
valueFromAccessor: ->
if @depth is 1 then super else @terminalProperty()?.getValue()
setValue: (val) -> if @depth is 1 then super else @terminalProperty()?.setValue(val)
unsetValue: -> if @depth is 1 then super else @terminalProperty()?.unsetValue() |
Observable | |
Batman.Observable is a generic mixin that can be applied to any object to allow it to be bound to.
It is applied by default to every instance of | Batman.Observable =
isObservable: true
property: (key) ->
Batman.initializeObject @
propertyClass = @propertyClass or Batman.Keypath
properties = @_batman.properties ||= new Batman.SimpleHash
properties.get(key) or properties.set(key, new propertyClass(this, key))
get: (key) ->
@property(key).getValue()
set: (key, val) ->
@property(key).setValue(val)
unset: (key) ->
@property(key).unsetValue()
getOrSet: (key, valueFunction) ->
currentValue = @get(key)
unless currentValue
currentValue = valueFunction()
@set(key, currentValue)
currentValue |
| forget: (key, observer) ->
if key
@property(key).forget(observer)
else
@_batman.properties.forEach (key, property) -> property.forget()
@ |
| observe: (key, args...) ->
@property(key).observe(args...)
@
observeAndFire: (key, args...) ->
@property(key).observeAndFire(args...)
@
$get = Batman.get = (base, key) ->
if base.get? && typeof base.get is 'function'
base.get(key)
else
Batman.Property.forBaseAndKey(base, key).getValue() |
Objects | |
| Batman.initializeObject = (object) ->
if object._batman?
object._batman.check(object)
else
object._batman = new _Batman(object) |
_Batman provides a convienient, parent class and prototype aware place to store hidden
object state. Things like observers, accessors, and states belong in the | Batman._Batman = class _Batman
constructor: (@object, mixins...) ->
$mixin(@, mixins...) if mixins.length > 0 |
Used by | check: (object) ->
if object != @object
object._batman = new _Batman(object)
return false
return true |
| get: (key) -> |
Get all the keys from the ancestor chain | results = @getAll(key)
switch results.length
when 0
undefined
when 1
results[0]
else |
And then try to merge them if there is more than one. Use | if results[0].concat?
results = results.reduceRight (a, b) -> a.concat(b)
else if results[0].merge?
results = results.reduceRight (a, b) -> a.merge(b)
results |
| getFirst: (key) ->
results = @getAll(key)
results[0] |
| getAll: (keyOrGetter) -> |
Get a function which pulls out the key from the ancestor's | if typeof keyOrGetter is 'function'
getter = keyOrGetter
else
getter = (ancestor) -> ancestor._batman?[keyOrGetter] |
Apply it to all the ancestors, and then this | results = @ancestors(getter)
if val = getter(@object)
results.unshift val
results |
| ancestors: (getter = (x) -> x) ->
results = [] |
Decide if the object is a class or not, and pull out the first ancestor | isClass = !!@object.prototype
parent = if isClass
@object.__super__?.constructor
else
if (proto = Object.getPrototypeOf(@object)) == @object
@object.constructor.__super__
else
proto
if parent? |
Apply the function and store the result if it isn't undefined. | val = getter(parent)
results.push(val) if val? |
Use a recursive call to | results = results.concat(parent._batman.ancestors(getter)) if parent._batman?
results
set: (key, value) ->
@[key] = value |
| class BatmanObject
Batman.initializeObject(this)
Batman.initializeObject(@prototype) |
Setting | @global: (isGlobal) ->
return if isGlobal is false
container[$functionName(@)] = @ |
Apply mixins to this class. | @classMixin: -> $mixin @, arguments... |
Apply mixins to instances of this class. | @mixin: -> @classMixin.apply @prototype, arguments
mixin: @classMixin
counter = 0
_objectID: ->
@_objectID = -> c
c = counter++
hashKey: ->
return if typeof @isEqual is 'function'
@hashKey = -> key
key = "<Batman.Object #{@_objectID()}>"
toJSON: ->
obj = {}
for own key, value of @ when key isnt "_batman"
obj[key] = if value.toJSON then value.toJSON() else value
obj |
Accessor implementation. Accessors are used to create properties on a class or prototype which can be fetched
with get, but are computed instead of just stored. This is a batman and old browser friendly version of
Accessors track which other properties they rely on for computation, and when those other properties change,
an accessor will recalculate its value and notifiy its observers. This way, when a source value is changed,
any dependent accessors will automatically update any bindings to them with a new value. Accessors accomplish
this feat by tracking
Note: This function gets called in all sorts of different contexts by various
other pointers to it, but it acts the same way on | getAccessorObject = (accessor) ->
accessor = {get: accessor} if !accessor.get && !accessor.set && !accessor.unset
accessor
@classAccessor: (keys..., accessor) ->
Batman.initializeObject @ |
Create a default accessor if no keys have been given. | if keys.length is 0 |
The | @_batman.defaultAccessor = getAccessorObject(accessor)
else |
Otherwise, add key accessors for each key given. | @_batman.keyAccessors ||= new Batman.SimpleHash
@_batman.keyAccessors.set(key, getAccessorObject(accessor)) for key in keys |
Support adding accessors to the prototype from within class defintions or after the class has been created
with | @accessor: -> @classAccessor.apply @prototype, arguments |
Support adding accessors to instances after creation | accessor: @classAccessor
constructor: (mixins...) ->
@_batman = new _Batman(@)
@mixin mixins... |
Make every subclass and their instances observable. | @classMixin Batman.EventEmitter, Batman.Observable
@mixin Batman.EventEmitter, Batman.Observable |
Observe this property on every instance of this class. | @observeAll: -> @::observe.apply @prototype, arguments
@singleton: (singletonMethodName="sharedInstance") ->
@classAccessor singletonMethodName,
get: -> @["_#{singletonMethodName}"] ||= new @
Batman.Object = BatmanObject
class Batman.Accessible extends Batman.Object
constructor: -> @accessor.apply(@, arguments)
class Batman.TerminalAccessible extends Batman.Accessible
propertyClass: Batman.Property |
Collections | Batman.Enumerable =
isEnumerable: true
map: (f, ctx = container) -> r = []; @forEach(-> r.push f.apply(ctx, arguments)); r
every: (f, ctx = container) -> r = true; @forEach(-> r = r && f.apply(ctx, arguments)); r
some: (f, ctx = container) -> r = false; @forEach(-> r = r || f.apply(ctx, arguments)); r
reduce: (f, r) ->
count = 0
self = @
@forEach -> if r? then r = f(r, arguments..., count, self) else r = arguments[0]
r
filter: (f) ->
r = new @constructor
if r.add
wrap = (r, e) -> r.add(e) if f(e); r
else if r.set
wrap = (r, k, v) -> r.set(k, v) if f(k, v); r
else
r = [] unless r.push
wrap = (r, e) -> r.push(e) if f(e); r
@reduce wrap, r |
Provide this simple mixin ability so that during bootstrapping we don't have to use | $extendsEnumerable = (onto) -> onto[k] = v for k,v of Batman.Enumerable
class Batman.SimpleHash
constructor: ->
@_storage = {}
@length = 0
$extendsEnumerable(@::)
propertyClass: Batman.Property
hasKey: (key) ->
if pairs = @_storage[@hashKeyFor(key)]
for pair in pairs
return true if @equality(pair[0], key)
return false
get: (key) ->
if pairs = @_storage[@hashKeyFor(key)]
for pair in pairs
return pair[1] if @equality(pair[0], key)
set: (key, val) ->
pairs = @_storage[@hashKeyFor(key)] ||= []
for pair in pairs
if @equality(pair[0], key)
return pair[1] = val
@length++
pairs.push([key, val])
val
unset: (key) ->
if pairs = @_storage[@hashKeyFor(key)]
for [obj,value], index in pairs
if @equality(obj, key)
pair = pairs.splice(index,1)
@length--
return pair[0][1]
getOrSet: Batman.Observable.getOrSet
hashKeyFor: (obj) -> obj?.hashKey?() or obj
equality: (lhs, rhs) ->
return true if lhs is rhs
return true if lhs isnt lhs and rhs isnt rhs # when both are NaN
return true if lhs?.isEqual?(rhs) and rhs?.isEqual?(lhs)
return false
forEach: (iterator) ->
for key, values of @_storage
iterator(obj, value) for [obj, value] in values.slice()
keys: ->
result = [] |
Explicitly reference this foreach so that if it's overriden in subclasses the new implementation isn't used. | Batman.SimpleHash::forEach.call @, (key) -> result.push key
result
clear: ->
@_storage = {}
@length = 0
isEmpty: ->
@length is 0
merge: (others...) ->
merged = new @constructor
others.unshift(@)
for hash in others
hash.forEach (obj, value) ->
merged.set obj, value
merged
class Batman.Hash extends Batman.Object
constructor: ->
Batman.SimpleHash.apply(@, arguments) |
Add a meta object to all hashes which we can then use in the | @meta = new Batman.Object
self = this
@meta.accessor 'length', ->
self.registerAsMutableSource()
self.length
@meta.accessor 'isEmpty', -> self.isEmpty()
@meta.accessor 'keys', -> self.keys()
super
$extendsEnumerable(@::)
propertyClass: Batman.Property
@accessor
get: Batman.SimpleHash::get
set: @mutation (key, value) ->
result = Batman.SimpleHash::set.call(@, key, value)
@fire 'itemsWereAdded', key
result
unset: @mutation (key) ->
result = Batman.SimpleHash::unset.call(@, key)
@fire 'itemsWereRemoved', key if result?
result
clear: @mutation ->
keys = @meta.get('keys')
result = Batman.SimpleHash::clear.call(@)
@fire 'itemsWereRemoved', keys...
result
equality: Batman.SimpleHash::equality
hashKeyFor: Batman.SimpleHash::hashKeyFor
toJSON: ->
obj = {}
@keys().forEach (key) =>
value = @get key
obj[key] = if value.toJSON then value.toJSON() else value
obj
for k in ['hasKey', 'forEach', 'isEmpty', 'keys', 'merge']
proto = @prototype
do (k) ->
proto[k] = ->
@registerAsMutableSource()
Batman.SimpleHash::[k].apply(@, arguments)
class Batman.SimpleSet
constructor: ->
@_storage = new Batman.SimpleHash
@_indexes = new Batman.SimpleHash
@_sorts = new Batman.SimpleHash
@length = 0
@add.apply @, arguments if arguments.length > 0
$extendsEnumerable(@::)
has: (item) ->
@_storage.hasKey item
add: (items...) ->
addedItems = []
for item in items when !@_storage.hasKey(item)
@_storage.set item, true
addedItems.push item
@length++
if @fire and addedItems.length isnt 0
@fire('change', this, this)
@fire('itemsWereAdded', addedItems...)
addedItems
remove: (items...) ->
removedItems = []
for item in items when @_storage.hasKey(item)
@_storage.unset item
removedItems.push item
@length--
if @fire and removedItems.length isnt 0
@fire('change', this, this)
@fire('itemsWereRemoved', removedItems...)
removedItems
forEach: (iterator) ->
@_storage.forEach (key, value) -> iterator(key)
isEmpty: -> @length is 0
clear: ->
items = @toArray()
@_storage = new Batman.SimpleHash
@length = 0
if @fire and items.length isnt 0
@fire('change', this, this)
@fire('itemsWereRemoved', items...)
items
toArray: ->
@_storage.keys()
merge: (others...) ->
merged = new @constructor
others.unshift(@)
for set in others
set.forEach (v) -> merged.add v
merged
indexedBy: (key) ->
@_indexes.get(key) or @_indexes.set(key, new Batman.SetIndex(@, key))
sortedBy: (key, order="asc") ->
order = if order.toLowerCase() is "desc" then "desc" else "asc"
sortsForKey = @_sorts.get(key) or @_sorts.set(key, new Batman.Object)
sortsForKey.get(order) or sortsForKey.set(order, new Batman.SetSort(@, key, order))
class Batman.Set extends Batman.Object
constructor: ->
Batman.SimpleSet.apply @, arguments
$extendsEnumerable(@::)
for k in ['add', 'remove', 'clear', 'indexedBy', 'sortedBy']
@::[k] = Batman.SimpleSet::[k]
for k in ['merge', 'forEach', 'toArray', 'isEmpty', 'has']
proto = @prototype
do (k) ->
proto[k] = ->
@registerAsMutableSource()
Batman.SimpleSet::[k].apply(@, arguments)
toJSON: @::toArray
@accessor 'indexedBy', -> new Batman.TerminalAccessible (key) => @indexedBy(key)
@accessor 'sortedBy', -> new Batman.TerminalAccessible (key) => @sortedBy(key)
@accessor 'sortedByDescending', -> new Batman.TerminalAccessible (key) => @sortedBy(key, 'desc')
@accessor 'isEmpty', -> @isEmpty()
@accessor 'toArray', -> @toArray()
@accessor 'length', ->
@registerAsMutableSource()
@length
class Batman.SetObserver extends Batman.Object
constructor: (@base) ->
@_itemObservers = new Batman.Hash
@_setObservers = new Batman.Hash
@_setObservers.set "itemsWereAdded", => @fire('itemsWereAdded', arguments...)
@_setObservers.set "itemsWereRemoved", => @fire('itemsWereRemoved', arguments...)
@on 'itemsWereAdded', @startObservingItems.bind(@)
@on 'itemsWereRemoved', @stopObservingItems.bind(@)
observedItemKeys: []
observerForItemAndKey: (item, key) ->
_getOrSetObserverForItemAndKey: (item, key) ->
@_itemObservers.getOrSet item, =>
observersByKey = new Batman.Hash
observersByKey.getOrSet key, =>
@observerForItemAndKey(item, key)
startObserving: ->
@_manageItemObservers("observe")
@_manageSetObservers("addHandler")
stopObserving: ->
@_manageItemObservers("forget")
@_manageSetObservers("removeHandler")
startObservingItems: (items...) ->
@_manageObserversForItem(item, "observe") for item in items
stopObservingItems: (items...) ->
@_manageObserversForItem(item, "forget") for item in items
_manageObserversForItem: (item, method) ->
return unless item.isObservable
for key in @observedItemKeys
item[method] key, @_getOrSetObserverForItemAndKey(item, key)
@_itemObservers.unset(item) if method is "forget"
_manageItemObservers: (method) ->
@base.forEach (item) => @_manageObserversForItem(item, method)
_manageSetObservers: (method) ->
return unless @base.isObservable
@_setObservers.forEach (key, observer) =>
@base.event(key)[method](observer)
class Batman.SetProxy extends Batman.Object
constructor: () ->
super()
@length = 0
$extendsEnumerable(@::)
filter: (f) ->
r = new Batman.Set()
@reduce(((r, e) -> r.add(e) if f(e); r), r)
for k in ['add', 'remove', 'clear']
do (k) =>
@::[k] = ->
results = @base[k](arguments...)
@length = @set('length', @base.get 'length')
results
for k in ['has', 'merge', 'toArray', 'isEmpty']
do (k) =>
@::[k] = -> @base[k](arguments...)
for k in ['isEmpty', 'toArray']
do (k) =>
@accessor k, -> @base.get(k)
@accessor 'length'
get: ->
@registerAsMutableSource()
@length
set: (k, v) ->
@length = v
class Batman.SetSort extends Batman.SetProxy
constructor: (@base, @key, order="asc") ->
super()
@descending = order.toLowerCase() is "desc"
if @base.isObservable
@_setObserver = new Batman.SetObserver(@base)
@_setObserver.observedItemKeys = [@key]
boundReIndex = @_reIndex.bind(@)
@_setObserver.observerForItemAndKey = -> boundReIndex
@_setObserver.on 'itemsWereAdded', boundReIndex
@_setObserver.on 'itemsWereRemoved', boundReIndex
@startObserving()
@_reIndex()
startObserving: -> @_setObserver?.startObserving()
stopObserving: -> @_setObserver?.stopObserving()
toArray: -> @get('_storage')
@accessor 'toArray', @::toArray
forEach: (iterator) -> iterator(e,i) for e,i in @get('_storage')
compare: (a,b) ->
return 0 if a is b
return 1 if a is undefined
return -1 if b is undefined
return 1 if a is null
return -1 if b is null
return 0 if a.isEqual?(b) and b.isEqual?(a)
typeComparison = Batman.SetSort::compare($typeOf(a), $typeOf(b))
return typeComparison if typeComparison isnt 0
return 1 if a isnt a # means a is NaN
return -1 if b isnt b # means b is NaN
return 1 if a > b
return -1 if a < b
return 0
_reIndex: ->
newOrder = @base.toArray().sort (a,b) =>
valueA = $get(a, @key)
valueA = valueA.valueOf() if valueA?
valueB = $get(b, @key)
valueB = valueB.valueOf() if valueB?
multiple = if @descending then -1 else 1
@compare.call(@, valueA, valueB) * multiple
@_setObserver?.startObservingItems(newOrder...)
@set('_storage', newOrder)
class Batman.SetIndex extends Batman.Object
constructor: (@base, @key) ->
super()
@_storage = new Batman.Hash
if @base.isEventEmitter
@_setObserver = new Batman.SetObserver(@base)
@_setObserver.observedItemKeys = [@key]
@_setObserver.observerForItemAndKey = @observerForItemAndKey.bind(@)
@_setObserver.on 'itemsWereAdded', (items...) =>
@_addItem(item) for item in items
@_setObserver.on 'itemsWereRemoved', (items...) =>
@_removeItem(item) for item in items
@base.forEach @_addItem.bind(@)
@startObserving()
@accessor (key) -> @_resultSetForKey(key)
startObserving: ->@_setObserver?.startObserving()
stopObserving: -> @_setObserver?.stopObserving()
observerForItemAndKey: (item, key) ->
(newValue, oldValue) =>
@_removeItemFromKey(item, oldValue)
@_addItemToKey(item, newValue)
_addItem: (item) -> @_addItemToKey(item, @_keyForItem(item))
_addItemToKey: (item, key) ->
@_resultSetForKey(key).add item
_removeItem: (item) -> @_removeItemFromKey(item, @_keyForItem(item))
_removeItemFromKey: (item, key) ->
@_resultSetForKey(key).remove item
_resultSetForKey: (key) ->
@_storage.getOrSet(key, -> new Batman.Set)
_keyForItem: (item) ->
Batman.Keypath.forBaseAndKey(item, @key).getValue()
class Batman.UniqueSetIndex extends Batman.SetIndex
constructor: ->
@_uniqueIndex = new Batman.Hash
super
@accessor (key) -> @_uniqueIndex.get(key)
_addItemToKey: (item, key) ->
@_resultSetForKey(key).add item
unless @_uniqueIndex.hasKey(key)
@_uniqueIndex.set(key, item)
_removeItemFromKey: (item, key) ->
resultSet = @_resultSetForKey(key)
resultSet.remove item
if resultSet.length is 0
@_uniqueIndex.unset(key)
else
@_uniqueIndex.set(key, resultSet.toArray()[0]) |
State Machines | Batman.StateMachine = {
initialize: ->
Batman.initializeObject @
if not @_batman.states
@_batman.states = new Batman.SimpleHash
state: (name, callback) ->
Batman.StateMachine.initialize.call @
return @_batman.getFirst 'state' unless name
developer.assert @isEventEmitter, "StateMachine requires EventEmitter"
@[name] ||= (callback) -> _stateMachine_setState.call(@, name)
@on(name, callback) if typeof callback is 'function'
transition: (from, to, callback) ->
Batman.StateMachine.initialize.call @
@state from
@state to
@on("#{from}->#{to}", callback) if callback
} |
A special method to alias state machine methods to class methods | Batman.Object.actsAsStateMachine = (includeInstanceMethods=true) ->
Batman.StateMachine.initialize.call @
Batman.StateMachine.initialize.call @prototype
@classState = -> Batman.StateMachine.state.apply @, arguments
@state = -> @classState.apply @prototype, arguments
@::state = @classState if includeInstanceMethods
@classTransition = -> Batman.StateMachine.transition.apply @, arguments
@transition = -> @classTransition.apply @prototype, arguments
@::transition = @classTransition if includeInstanceMethods |
This is cached here so it doesn't need to be recompiled for every setter | _stateMachine_setState = (newState) ->
Batman.StateMachine.initialize.call @
if @_batman.isTransitioning
(@_batman.nextState ||= []).push(newState)
return false
@_batman.isTransitioning = yes
oldState = @state()
@_batman.state = newState
if newState and oldState
@fire("#{oldState}->#{newState}", newState, oldState)
if newState
@fire(newState, newState, oldState)
@_batman.isTransitioning = no
@[@_batman.nextState.shift()]() if @_batman.nextState?.length
newState |
App, Requests, and Routing | |
| class Batman.Request extends Batman.Object
@objectToFormData: (data) ->
pairForList = (key, object, first = false) ->
list = switch Batman.typeOf(object)
when 'Object'
list = for k, v of object
pairForList((if first then k else "#{key}[#{k}]"), v)
list.reduce((acc, list) ->
acc.concat list
, [])
when 'Array'
object.reduce((acc, element) ->
acc.concat pairForList("#{key}[]", element)
, [])
else
[[key, object]]
formData = new FormData()
for [key, val] in pairForList("", data, true)
formData.append(key, val)
formData
url: ''
data: ''
method: 'get'
formData: false
response: null
status: null |
Set the content type explicitly for PUT and POST requests. | contentType: 'application/x-www-form-urlencoded'
constructor: (options) ->
handlers = {}
for k, handler of options when k in ['success', 'error', 'loading', 'loaded']
handlers[k] = handler
delete options[k]
super(options)
@on k, handler for k, handler of handlers |
After the URL gets set, we'll try to automatically send your request after a short period. If this behavior is not desired, use @cancel() after setting the URL. | @observeAll 'url', ->
@_autosendTimeout = setTimeout (=> @send()), 0 |
| send: () -> developer.error "Please source a dependency file for a request implementation"
cancel: ->
clearTimeout(@_autosendTimeout) if @_autosendTimeout |
| class Batman.App extends Batman.Object |
Require path tells the require methods which base directory to look in. | @requirePath: '' |
The require class methods ( | developer.do ->
App.require = (path, names...) ->
base = @requirePath + path
for name in names
@prevent 'run'
path = base + '/' + name + '.coffee'
new Batman.Request
url: path
type: 'html'
success: (response) =>
CoffeeScript.eval response
@allow 'run'
if not @isPrevented 'run'
@fire 'loaded'
@run() if @wantsToRun
@
@controller: (names...) ->
names = names.map (n) -> n + '_controller'
@require 'controllers', names...
@model: ->
@require 'models', arguments...
@view: ->
@require 'views', arguments... |
Layout is the base view that other views can be yielded into. The
default behavior is that when | @layout: undefined |
Call | @event('run').oneShot = true
@run: ->
if Batman.currentApp
return if Batman.currentApp is @
Batman.currentApp.stop()
return false if @hasRun
if @isPrevented 'run'
@wantsToRun = true
return false
else
delete @wantsToRun
Batman.currentApp = @
if typeof @dispatcher is 'undefined'
@dispatcher ||= new Batman.Dispatcher @
if typeof @layout is 'undefined'
@set 'layout', new Batman.View
contexts: [@]
node: document
@get('layout').on 'ready', => @fire 'ready'
if typeof @historyManager is 'undefined' and @dispatcher.routeMap
@on 'run', =>
@historyManager = Batman.historyManager = new Batman.HashHistory @
@historyManager.start()
@hasRun = yes
@fire('run')
@
@event('ready').oneShot = true
@event('stop').oneShot = true
@stop: ->
@historyManager?.stop()
Batman.historyManager = null
@hasRun = no
@fire('stop')
@ |
Dispatcher | class Batman.Route extends Batman.Object |
Route regexes courtesy of Backbone | namedParam = /:([\w\d]+)/g
splatParam = /\*([\w\d]+)/g
queryParam = '(?:\\?.+)?'
namedOrSplat = /[:|\*]([\w\d]+)/g
escapeRegExp = /[-[\]{}()+?.,\\^$|#\s]/g
constructor: ->
super
@pattern = @url.replace(escapeRegExp, '\\$&')
@regexp = new RegExp('^' + @pattern.replace(namedParam, '([^\/]*)').replace(splatParam, '(.*?)') + queryParam + '$')
@namedArguments = []
while (array = namedOrSplat.exec(@pattern))?
@namedArguments.push(array[1]) if array[1]
@accessor 'action',
get: ->
return @action if @action
if @options
result = $mixin {}, @options
if signature = result.signature
components = signature.split('#')
result.controller = components[0]
result.action = components[1] || 'index'
result.target = @dispatcher.get result.controller
@set 'action', result
set: (key, action) ->
@action = action
parameterize: (url) ->
[url, query] = url.split '?'
array = @regexp.exec(url)?.slice(1)
params = url: url
action = @get 'action'
if typeof action is 'function'
params.action = action
else
$mixin params, action
if array
for param, index in array
params[@namedArguments[index]] = param
if query
for s in query.split '&'
[key, value] = s.split '='
params[key] = value
params
dispatch: (url) ->
if $typeOf(url) is 'String'
params = @parameterize url
$redirect('/404') if not (action = params.action) and url isnt '/404'
return action(params) if typeof action is 'function'
return params.target.dispatch(action, params) if params.target?.dispatch
return params.target?[action](params)
class Batman.Dispatcher extends Batman.Object
constructor: (@app) ->
@app.route @
@app.controllers = new Batman.Object
for key, controller of @app
continue unless controller?.prototype instanceof Batman.Controller
@prepareController controller
prepareController: (controller) ->
name = helpers.underscore($functionName(controller).replace('Controller', ''))
return unless name
getter = -> @[name] = controller.get 'sharedController'
@accessor name, getter
@app.controllers.accessor name, getter
register: (url, options) ->
url = "/#{url}" if url.indexOf('/') isnt 0
route = if $typeOf(options) is 'Function'
new Batman.Route url: url, action: options, dispatcher: @
else
new Batman.Route url: url, options: options, dispatcher: @
@routeMap ||= {}
@routeMap[url] = route
findRoute: (url) ->
url = "/#{url}" if url.indexOf('/') isnt 0
return route if (route = @routeMap[url])
for routeUrl, route of @routeMap
return route if route.regexp.test(url)
findUrl: (params) ->
for url, route of @routeMap
matches = no
options = route.options
if params.resource
matches = options.resource is params.resource and
options.action is params.action
else
action = route.get 'action'
continue if typeof action is 'function'
{controller, action} = action
if controller is params.controller and action is (params.action || 'index')
matches = yes
continue if not matches
for key, value of params
url = url.replace new RegExp('[:|\*]' + key), value
return url
dispatch: (url) ->
route = @findRoute(url)
if route
route.dispatch(url)
else if url isnt '/404'
$redirect('/404')
@app.set 'currentURL', url
@app.set 'currentRoute', route |
History Manager | class Batman.HistoryManager
constructor: (@app) ->
dispatch: (url) ->
url = "/#{url}" if url.indexOf('/') isnt 0
@app.dispatcher.dispatch url
url
redirect: (url) ->
if $typeOf(url) isnt 'String'
url = @app.dispatcher.findUrl(url)
@dispatch url
class Batman.HashHistory extends Batman.HistoryManager
HASH_PREFIX: '#!'
start: =>
return if typeof window is 'undefined'
return if @started
@started = yes
if 'onhashchange' of window
$addEventListener window, 'hashchange', @parseHash
else
@interval = setInterval @parseHash, 100
@first = true
Batman.currentApp.prevent 'ready'
setTimeout @parseHash, 0
stop: =>
if @interval
@interval = clearInterval @interval
else
$removeEventListener window, 'hashchange', @parseHash
@started = no
urlFor: (url) ->
@HASH_PREFIX + url
parseHash: =>
hash = window.location.hash.replace @HASH_PREFIX, ''
return if hash is @cachedHash
result = @dispatch (@cachedHash = hash)
if @first
Batman.currentApp.allow 'ready'
Batman.currentApp.fire 'ready'
@first = false
result
redirect: (params) ->
url = super
@cachedHash = url
window.location.hash = @HASH_PREFIX + url
Batman.redirect = $redirect = (url) ->
Batman.historyManager?.redirect url |
Route Declarators | Batman.App.classMixin
route: (url, signature, options={}) ->
return if not url
if url instanceof Batman.Dispatcher
dispatcher = url
for key, value of @_dispatcherCache
dispatcher.register key, value
@_dispatcherCache = null
return dispatcher
if $typeOf(signature) is 'String'
options.signature = signature
else if $typeOf(signature) is 'Function'
options = signature
else if signature
$mixin options, signature
@_dispatcherCache ||= {}
@_dispatcherCache[url] = options
root: (signature, options) ->
@route '/', signature, options
resources: (resource, options={}, callback) ->
(callback = options; options = {}) if typeof options is 'function'
resource = helpers.pluralize(resource)
controller = options.controller || resource
@route(resource, "#{controller}#index", resource: controller, action: 'index') unless options.index is false
@route("#{resource}/new", "#{controller}#new", resource: controller, action: 'new') unless options.new is false
@route("#{resource}/:id", "#{controller}#show", resource: controller, action: 'show') unless options.show is false
@route("#{resource}/:id/edit", "#{controller}#edit", resource: controller, action: 'edit') unless options.edit is false
if callback
app = @
ops =
collection: (collectionCallback) ->
collectionCallback?.call route: (url, methodName) -> app.route "#{resource}/#{url}", "#{controller}##{methodName || url}"
member: (memberCallback) ->
memberCallback?.call route: (url, methodName) -> app.route "#{resource}/:id/#{url}", "#{controller}##{methodName || url}"
callback.call ops
redirect: $redirect |
Controllers | class Batman.Controller extends Batman.Object
@singleton 'sharedController'
@beforeFilter: (nameOrFunction) ->
Batman.initializeObject @
filters = @_batman.beforeFilters ||= []
filters.push(nameOrFunction) if filters.indexOf(nameOrFunction) is -1
@accessor 'controllerName',
get: -> @_controllerName ||= helpers.underscore($functionName(@constructor).replace('Controller', ''))
@afterFilter: (nameOrFunction) ->
Batman.initializeObject @
filters = @_batman.afterFilters ||= []
filters.push(nameOrFunction) if filters.indexOf(nameOrFunction) is -1
@accessor 'action',
get: -> @_currentAction
set: (key, value) -> @_currentAction = value |
You shouldn't call this method directly. It will be called by the dispatcher when a route is called.
If you need to call a route manually, use | dispatch: (action, params = {}) ->
params.controller ||= @get 'controllerName'
params.action ||= action
params.target ||= @
oldRedirect = Batman.historyManager?.redirect
Batman.historyManager?.redirect = @redirect
@_actedDuringAction = no
@set 'action', action
if filters = @constructor._batman?.get('beforeFilters')
for filter in filters
if typeof filter is 'function' then filter.call(@, params) else @[filter](params)
developer.assert @[action], "Error! Controller action #{@get 'controllerName'}.#{action} couldn't be found!"
@[action](params)
if not @_actedDuringAction
@render()
if filters = @constructor._batman?.get('afterFilters')
for filter in filters
if typeof filter is 'function' then filter.call(@, params) else @[filter](params)
delete @_actedDuringAction
@set 'action', null
Batman.historyManager?.redirect = oldRedirect
redirectTo = @_afterFilterRedirect
delete @_afterFilterRedirect
$redirect(redirectTo) if redirectTo
redirect: (url) =>
throw 'DoubleRedirectError' if @_actedDuringAction
if @get 'action'
@_actedDuringAction = yes
@_afterFilterRedirect = url
else
if $typeOf(url) is 'Object'
url.controller = @ if not url.controller
$redirect url
render: (options = {}) ->
throw 'DoubleRenderError' if @_actedDuringAction
@_actedDuringAction = yes
return if options is false
if not options.view
options.source ||= helpers.underscore($functionName(@constructor).replace('Controller', '')) + '/' + @_currentAction + '.html'
options.view = new Batman.View(options)
if view = options.view
Batman.currentApp?.prevent 'ready'
view.contexts.push @
view.on 'ready', ->
Batman.DOM.replace 'main', view.get('node')
Batman.currentApp?.allow 'ready'
Batman.currentApp?.fire 'ready'
view |
Models | class Batman.Model extends Batman.Object |
Model APIOverride this property if your model is indexed by a key other than | @primaryKey: 'id' |
Override this property to define the key which storage adapters will use to store instances of this model under. - For RestStorage, this ends up being part of the url built to store this model - For LocalStorage, this ends up being the namespace in localStorage in which JSON is stored | @storageKey: null |
Pick one or many mechanisms with which this model should be persisted. The mechanisms can be already instantiated or just the class defining them. | @persist: (mechanisms...) ->
Batman.initializeObject @prototype
storage = @::_batman.storage ||= []
results = for mechanism in mechanisms
mechanism = if mechanism.isStorageAdapter then mechanism else new mechanism(@)
storage.push mechanism
mechanism
if results.length > 1
results
else
results[0] |
Encoders are the tiny bits of logic which manage marshalling Batman models to and from their storage representations. Encoders do things like stringifying dates and parsing them back out again, pulling out nested model collections and instantiating them (and JSON.stringifying them back again), and marshalling otherwise un-storable object. | @encode: (keys..., encoderOrLastKey) ->
Batman.initializeObject @prototype
@::_batman.encoders ||= new Batman.SimpleHash
@::_batman.decoders ||= new Batman.SimpleHash
switch $typeOf(encoderOrLastKey)
when 'String'
keys.push encoderOrLastKey
when 'Function'
encoder = encoderOrLastKey
else
encoder = encoderOrLastKey.encode
decoder = encoderOrLastKey.decode
encoder = @defaultEncoder.encode if typeof encoder is 'undefined'
decoder = @defaultEncoder.decode if typeof decoder is 'undefined'
for key in keys
@::_batman.encoders.set(key, encoder) if encoder
@::_batman.decoders.set(key, decoder) if decoder |
Set up the unit functions as the default for both | @defaultEncoder:
encode: (x) -> x
decode: (x) -> x |
Attach encoders and decoders for the primary key, and update them if the primary key changes. | @observeAndFire 'primaryKey', (newPrimaryKey) -> @encode newPrimaryKey, {encode: false, decode: @defaultEncoder.decode} |
Validations allow a model to be marked as 'valid' or 'invalid' based on a set of programmatic rules.
By validating our data before it gets to the server we can provide immediate feedback to the user about
what they have entered and forgo waiting on a round trip to the server.
| @validate: (keys..., optionsOrFunction) ->
Batman.initializeObject @prototype
validators = @::_batman.validators ||= []
if typeof optionsOrFunction is 'function' |
Given a function, use that as the actual validator, expecting it to conform to the API the built in validators do. | validators.push
keys: keys
callback: optionsOrFunction
else |
Given options, find the validations which match the given options, and add them to the validators array. | options = optionsOrFunction
for validator in Validators
if (matches = validator.matches(options))
delete options[match] for match in matches
validators.push
keys: keys
validator: new validator(matches) |
Query methods | @classAccessor 'all',
get: ->
@load() if @::hasStorage() and @classState() not in ['loaded', 'loading']
@get('loaded')
set: (k, v) -> @set('loaded', v)
@classAccessor 'loaded',
get: -> @_loaded ||= new Batman.Set
set: (k, v) -> @_loaded = v
@classAccessor 'first', -> @get('all').toArray()[0]
@classAccessor 'last', -> x = @get('all').toArray(); x[x.length - 1]
@find: (id, callback) ->
developer.assert callback, "Must call find with a callback!"
record = new @()
record.set 'id', id
newRecord = @_mapIdentity(record)
newRecord.load callback
return newRecord |
| @load: (options, callback) ->
if $typeOf(options) is 'Function'
callback = options
options = {}
developer.assert @::_batman.getAll('storage').length, "Can't load model #{$functionName(@)} without any storage adapters!"
@loading()
@::_doStorageOperation 'readAll', options, (err, records) =>
if err?
callback?(err, [])
else
mappedRecords = (@_mapIdentity(record) for record in records)
@loaded()
callback?(err, mappedRecords) |
| @create: (attrs, callback) ->
if !callback
[attrs, callback] = [{}, attrs]
obj = new this(attrs)
obj.save(callback)
obj |
| @findOrCreate: (attrs, callback) ->
record = new this(attrs)
if record.isNew()
record.save(callback)
else
foundRecord = @_mapIdentity(record)
foundRecord.updateAttributes(attrs)
callback(undefined, foundRecord)
@_mapIdentity: (record) ->
if typeof (id = record.get('id')) == 'undefined' || id == ''
return record
else
existing = @get("loaded.indexedBy.id").get(id)?.toArray()[0]
if existing
existing.updateAttributes(record._batman.attributes || {})
return existing
else
@get('loaded').add(record)
return record |
Record API | |
Add a universally accessible accessor for retrieving the primrary key, regardless of which key its stored under. | @accessor 'id',
get: ->
pk = @constructor.get('primaryKey')
if pk == 'id'
@id
else
@get(pk)
set: (k, v) -> |
naively coerce string ids into integers | if typeof v is "string" and !isNaN(intId = parseInt(v, 10))
v = intId
pk = @constructor.get('primaryKey')
if pk == 'id'
@id = v
else
@set(pk, v) |
Add normal accessors for the dirty keys and errors attributes of a record, so these accesses don't fall to the default accessor. | @accessor 'dirtyKeys', 'errors', Batman.Property.defaultAccessor |
Add an accessor for the internal batman state under | @accessor 'batmanState'
get: -> @state()
set: (k, v) -> @state(v) |
Add a default accessor to make models store their attributes under a namespace by default. | @accessor Model.defaultAccessor =
get: (k) -> (@_batman.attributes ||= {})[k] || @[k]
set: (k, v) -> (@_batman.attributes ||= {})[k] = v
unset: (k) ->
x = (@_batman.attributes ||={})[k]
delete @_batman.attributes[k]
x |
New records can be constructed by passing either an ID or a hash of attributes (potentially containing an ID) to the Model constructor. By not passing an ID, the model is marked as new. | constructor: (idOrAttributes = {}) ->
developer.assert @ instanceof Batman.Object, "constructors must be called with new" |
We have to do this ahead of super, because mixins will call set which calls things on dirtyKeys. | @dirtyKeys = new Batman.Hash
@errors = new Batman.ErrorsSet |
Find the ID from either the first argument or the attributes. | if $typeOf(idOrAttributes) is 'Object'
super(idOrAttributes)
else
super()
@set('id', idOrAttributes)
@empty() if not @state() |
Override the | set: (key, value) -> |
Optimize setting where the value is the same as what's already been set. | oldValue = @get(key)
return if oldValue is value |
Actually set the value and note what the old value was in the tracking array. | result = super
@dirtyKeys.set(key, oldValue) |
Mark the model as dirty if isn't already. | @dirty() unless @state() in ['dirty', 'loading', 'creating']
result
updateAttributes: (attrs) ->
@mixin(attrs)
@
toString: ->
"#{$functionName(@constructor)}: #{@get('id')}" |
| toJSON: ->
obj = {} |
Encode each key into a new object | encoders = @_batman.get('encoders')
unless !encoders or encoders.isEmpty()
encoders.forEach (key, encoder) =>
val = @get key
if typeof val isnt 'undefined'
encodedVal = encoder(@get key)
if typeof encodedVal isnt 'undefined'
obj[key] = encodedVal
obj |
| fromJSON: (data) ->
obj = {}
decoders = @_batman.get('decoders') |
If no decoders were specified, do the best we can to interpret the given JSON by camelizing each key and just setting the values. | if !decoders or decoders.isEmpty()
for key, value of data
obj[key] = value
else |
If we do have decoders, use them to get the data. | decoders.forEach (key, decoder) ->
obj[key] = decoder(data[key]) if data[key] |
Mixin the buffer object to use optimized and event-preventing sets used by | @mixin obj |
Each model instance (each record) can be in one of many states throughout its lifetime. Since various operations on the model are asynchronous, these states are used to indicate exactly what point the record is at in it's lifetime, which can often be during a save or load operation. | @actsAsStateMachine yes |
Add the various states to the model. | for k in ['empty', 'dirty', 'loading', 'loaded', 'saving', 'saved', 'creating', 'created', 'validating', 'validated', 'destroying', 'destroyed']
@state k
for k in ['loading', 'loaded']
@classState k
_doStorageOperation: (operation, options, callback) ->
developer.assert @hasStorage(), "Can't #{operation} model #{$functionName(@constructor)} without any storage adapters!"
mechanisms = @_batman.get('storage')
for mechanism in mechanisms
mechanism[operation] @, options, callback
true
hasStorage: -> (@_batman.get('storage') || []).length > 0 |
| load: (callback) =>
if @state() in ['destroying', 'destroyed']
callback?(new Error("Can't load a destroyed record!"))
return
@loading()
@_doStorageOperation 'read', {}, (err, record) =>
unless err
@loaded()
record = @constructor._mapIdentity(record)
callback?(err, record) |
| save: (callback) =>
if @state() in ['destroying', 'destroyed']
callback?(new Error("Can't save a destroyed record!"))
return
@validate (isValid, errors) =>
if !isValid
callback?(errors)
return
creating = @isNew()
do @saving
do @creating if creating
@_doStorageOperation (if creating then 'create' else 'update'), {}, (err, record) =>
unless err
if creating
do @created
do @saved
@dirtyKeys.clear()
record = @constructor._mapIdentity(record)
callback?(err, record) |
| destroy: (callback) =>
do @destroying
@_doStorageOperation 'destroy', {}, (err, record) =>
unless err
@constructor.get('all').remove(@)
do @destroyed
callback?(err) |
| validate: (callback) ->
oldState = @state()
@errors.clear()
do @validating
finish = () =>
do @validated
@[oldState]()
callback?(@errors.length == 0, @errors)
validators = @_batman.get('validators') || []
unless validators.length > 0
finish()
else
count = validators.length
validationCallback = =>
if --count == 0
finish()
for validator in validators
v = validator.validator |
Run the validator | for key in validator.keys
if v
v.validateEach @errors, @, key, validationCallback
else
validator.callback @errors, @, key, validationCallback
return
isNew: -> typeof @get('id') is 'undefined'
class Batman.ValidationError extends Batman.Object
constructor: (attribute, message) -> super({attribute, message}) |
| class Batman.ErrorsSet extends Batman.Set |
Define a default accessor to get the set of errors on a key | @accessor (key) -> @indexedBy('attribute').get(key) |
Define a shorthand method for adding errors to a key. | add: (key, error) -> super(new Batman.ValidationError(key, error))
class Batman.Validator extends Batman.Object
constructor: (@options, mixins...) ->
super mixins...
validate: (record) -> developer.error "You must override validate in Batman.Validator subclasses."
format: (key, messageKey, interpolations) -> t('errors.format', {attribute: key, message: t("errors.messages.#{messageKey}", interpolations)})
@options: (options...) ->
Batman.initializeObject @
if @_batman.options then @_batman.options.concat(options) else @_batman.options = options
@matches: (options) ->
results = {}
shouldReturn = no
for key, value of options
if ~@_batman?.options?.indexOf(key)
results[key] = value
shouldReturn = yes
return results if shouldReturn
Validators = Batman.Validators = [
class Batman.LengthValidator extends Batman.Validator
@options 'minLength', 'maxLength', 'length', 'lengthWithin', 'lengthIn'
constructor: (options) ->
if range = (options.lengthIn or options.lengthWithin)
options.minLength = range[0]
options.maxLength = range[1] || -1
delete options.lengthWithin
delete options.lengthIn
super
validateEach: (errors, record, key, callback) ->
options = @options
value = record.get(key)
if options.minLength and value.length < options.minLength
errors.add key, @format(key, 'too_short', {count: options.minLength})
if options.maxLength and value.length > options.maxLength
errors.add key, @format(key, 'too_long', {count: options.maxLength})
if options.length and value.length isnt options.length
errors.add key, @format(key, 'wrong_length', {count: options.length})
callback()
class Batman.PresenceValidator extends Batman.Validator
@options 'presence'
validateEach: (errors, record, key, callback) ->
value = record.get(key)
if @options.presence and !value?
errors.add key, @format(key, 'blank')
callback()
]
$mixin Batman.translate.messages,
errors:
format: "%{attribute} %{message}"
messages:
too_short: "must be at least %{count} characters"
too_long: "must be less than %{count} characters"
wrong_length: "must be %{count} characters"
blank: "can't be blank"
class Batman.StorageAdapter extends Batman.Object
constructor: (model) ->
super(model: model, modelKey: model.get('storageKey') || helpers.pluralize(helpers.underscore($functionName(model))))
isStorageAdapter: true
@::_batman.check(@::)
for k in ['all', 'create', 'read', 'readAll', 'update', 'destroy']
for time in ['before', 'after']
do (k, time) =>
key = "#{time}#{helpers.capitalize(k)}"
@::[key] = (filter) ->
@_batman.check(@)
(@_batman["#{key}Filters"] ||= []).push filter
before: (keys..., callback) ->
@["before#{helpers.capitalize(k)}"](callback) for k in keys
after: (keys..., callback) ->
@["after#{helpers.capitalize(k)}"](callback) for k in keys
_filterData: (prefix, action, data...) -> |
Filter the data first with the beforeRead and then the beforeAll filters | (@_batman.get("#{prefix}#{helpers.capitalize(action)}Filters") || [])
.concat(@_batman.get("#{prefix}AllFilters") || [])
.reduce( (filteredData, filter) =>
filter.call(@, filteredData)
, data)
getRecordFromData: (data) ->
record = new @model()
record.fromJSON(data)
record
$passError = (f) ->
return (filterables) ->
if filterables[0]
filterables
else
err = filterables.shift()
filterables = f.call(@, filterables)
filterables.unshift(err)
filterables
class Batman.LocalStorage extends Batman.StorageAdapter
constructor: ->
if typeof window.localStorage is 'undefined'
return null
super
@storage = localStorage
@key_re = new RegExp("^#{@modelKey}(\\d+)$")
@nextId = 1
@_forAllRecords (k, v) ->
if matches = @key_re.exec(k)
@nextId = Math.max(@nextId, parseInt(matches[1], 10) + 1)
return
@::before 'create', 'update', $passError ([record, options]) ->
[JSON.stringify(record), options]
@::after 'read', $passError ([record, attributes, options]) ->
[record.fromJSON(JSON.parse(attributes)), attributes, options]
_forAllRecords: (f) ->
for i in [0...@storage.length]
k = @storage.key(i)
f.call(@, k, @storage.getItem(k))
getRecordFromData: (data) ->
record = super
@nextId = Math.max(@nextId, parseInt(record.get('id'), 10) + 1)
record
update: (record, options, callback) ->
[err, recordToSave] = @_filterData('before', 'update', undefined, record, options)
if !err
id = record.get('id')
if id?
@storage.setItem(@modelKey + id, recordToSave)
else
err = new Error("Couldn't get record primary key.")
callback(@_filterData('after', 'update', err, record, options)...)
create: (record, options, callback) ->
[err, recordToSave] = @_filterData('before', 'create', undefined, record, options)
if !err
id = record.get('id') || record.set('id', @nextId++)
if id?
key = @modelKey + id
if @storage.getItem(key)
err = new Error("Can't create because the record already exists!")
else
@storage.setItem(key, recordToSave)
else
err = new Error("Couldn't set record primary key on create!")
callback(@_filterData('after', 'create', err, record, options)...)
read: (record, options, callback) ->
[err, record] = @_filterData('before', 'read', undefined, record, options)
id = record.get('id')
if !err
if id?
attrs = @storage.getItem(@modelKey + id)
if !attrs
err = new Error("Couldn't find record!")
else
err = new Error("Couldn't get record primary key.")
callback(@_filterData('after', 'read', err, record, attrs, options)...)
readAll: (_, options, callback) ->
records = []
[err, options] = @_filterData('before', 'readAll', undefined, options)
if !err
@_forAllRecords (storageKey, data) ->
if keyMatches = @key_re.exec(storageKey)
records.push {data, id: keyMatches[1]}
callback(@_filterData('after', 'readAll', err, records, options)...)
@::after 'readAll', $passError ([allAttributes, options]) ->
allAttributes = for attributes in allAttributes
data = JSON.parse(attributes.data)
data[@model.primaryKey] ||= parseInt(attributes.id, 10)
data
[allAttributes, options]
@::after 'readAll', $passError ([allAttributes, options]) ->
matches = []
for data in allAttributes
match = true
for k, v of options
if data[k] != v
match = false
break
if match
matches.push data
[matches, options]
@::after 'readAll', $passError ([filteredAttributes, options]) ->
[@getRecordFromData(data) for data in filteredAttributes, filteredAttributes, options]
destroy: (record, options, callback) ->
[err, record] = @_filterData 'before', 'destroy', undefined, record, options
if !err
id = record.get('id')
if id?
key = @modelKey + id
if @storage.getItem key
@storage.removeItem key
else
err = new Error("Can't delete nonexistant record!")
else
err = new Error("Can't delete record without an primary key!")
callback(@_filterData('after', 'destroy', err, record, options)...)
class Batman.RestStorage extends Batman.StorageAdapter
defaultOptions:
type: 'json'
recordJsonNamespace: false
collectionJsonNamespace: false
serializeAsForm: true
constructor: ->
super
@recordJsonNamespace = helpers.singularize(@modelKey)
@collectionJsonNamespace = helpers.pluralize(@modelKey)
@::before 'create', 'update', $passError ([record, options]) ->
json = record.toJSON()
record = if @recordJsonNamespace
x = {}
x[@recordJsonNamespace] = json
x
else
json
record = JSON.stringify(record) unless @serializeAsForm
[record, options]
@::after 'create', 'read', 'update', $passError ([record, data, options]) ->
data = data[@recordJsonNamespace] if data[@recordJsonNamespace]
[record, data, options]
@::after 'create', 'read', 'update', $passError ([record, data, options]) ->
record.fromJSON(data)
[record, data, options]
optionsForRecord: (record, idRequired, callback) ->
if record.url
url = record.url?(record) || record.url
else
url = @model.url?() || @model.url || "/#{@modelKey}"
if idRequired || !record.isNew()
id = record.get('id')
if !id?
callback.call(@, new Error("Couldn't get record primary key!"))
return
url = url + "/" + id
unless url
callback.call @, new Error("Couldn't get model url!")
else
callback.call @, undefined, $mixin({}, @defaultOptions, {url})
optionsForCollection: (recordsOptions, callback) ->
url = @model.url?() || @model.url || "/#{@modelKey}"
unless url
callback.call @, new Error("Couldn't get collection url!")
else
callback.call @, undefined, $mixin {}, @defaultOptions, {url, data: $mixin({}, @defaultOptions.data, recordsOptions)}
create: (record, recordOptions, callback) ->
@optionsForRecord record, false, (err, options) ->
[err, data] = @_filterData('before', 'create', err, record, recordOptions)
if err
callback(err)
return
new Batman.Request $mixin options,
data: data
method: 'POST'
success: (data) => callback(@_filterData('after', 'create', undefined, record, data, recordOptions)...)
error: (error) => callback(@_filterData('after', 'create', error, record, error.request.get('response'), recordOptions)...)
update: (record, recordOptions, callback) ->
@optionsForRecord record, true, (err, options) ->
[err, data] = @_filterData('before', 'update', err, record, recordOptions)
if err
callback(err)
return
new Batman.Request $mixin options,
data: data
method: 'PUT'
success: (data) => callback(@_filterData('after', 'update', undefined, record, data, recordOptions)...)
error: (error) => callback(@_filterData('after', 'update', error, record, error.request.get('response'), recordOptions)...)
read: (record, recordOptions, callback) ->
@optionsForRecord record, true, (err, options) ->
[err, record, recordOptions] = @_filterData('before', 'read', err, record, recordOptions)
if err
callback(err)
return
new Batman.Request $mixin options,
data: recordOptions
method: 'GET'
success: (data) => callback(@_filterData('after', 'read', undefined, record, data, recordOptions)...)
error: (error) => callback(@_filterData('after', 'read', error, record, error.request.get('response'), recordOptions)...)
readAll: (_, recordsOptions, callback) ->
@optionsForCollection recordsOptions, (err, options) ->
[err, recordsOptions] = @_filterData('before', 'readAll', err, recordsOptions)
if err
callback(err)
return
if recordsOptions && recordsOptions.url
options.url = recordsOptions.url
delete recordsOptions.url
new Batman.Request $mixin options,
data: recordsOptions
method: 'GET'
success: (data) => callback(@_filterData('after', 'readAll', undefined, data, recordsOptions)...)
error: (error) => callback(@_filterData('after', 'readAll', error, error.request.get('response'), recordsOptions)...)
@::after 'readAll', $passError ([data, options]) ->
recordData = if data[@collectionJsonNamespace] then data[@collectionJsonNamespace] else data
[recordData, data, options]
@::after 'readAll', $passError ([recordData, serverData, options]) ->
[@getRecordFromData(attributes) for attributes in recordData, serverData, options]
destroy: (record, recordOptions, callback) ->
@optionsForRecord record, true, (err, options) ->
[err, record, recordOptions] = @_filterData('before', 'destroy', err, record, recordOptions)
if err
callback(err)
return
new Batman.Request $mixin options,
method: 'DELETE'
success: (data) => callback(@_filterData('after', 'destroy', undefined, record, data, recordOptions)...)
error: (error) => callback(@_filterData('after', 'destroy', error, record, error.request.get('response'), recordOptions)...) |
Views | |
A | class Batman.View extends Batman.Object
constructor: (options) ->
@contexts = []
super(options) |
Support both | if context = @get('context')
@contexts.push context
@unset('context')
@viewSources: {} |
Set the source attribute to an html file to have that file loaded. | source: '' |
Set the html to a string of html to have that html parsed. | html: '' |
Set an existing DOM node to parse immediately. | node: null
contentFor: null |
Fires once a node is parsed. | @::event('ready').oneShot = true |
Where to look for views on the server | prefix: 'views' |
Whenever the source changes we load it up asynchronously | @observeAll 'source', ->
setTimeout (=> @reloadSource()), 0
reloadSource: ->
source = @get 'source'
return if not source
if Batman.View.viewSources[source]
@set('html', Batman.View.viewSources[source])
else
new Batman.Request
url: url = "#{@prefix}/#{@source}"
type: 'html'
success: (response) =>
Batman.View.viewSources[source] = response
@set('html', response)
error: (response) ->
throw new Error("Could not load view from #{url}")
@observeAll 'html', (html) ->
node = @node || document.createElement 'div'
$setInnerHTML(node, html)
@set('node', node) if @node isnt node
@observeAll 'node', (node) ->
return unless node
@event('ready').resetOneShot()
if @_renderer
@_renderer.forgetAll() |
We use a renderer with the continuation style rendering engine to not block user interaction for too long during the render. | if node
@_renderer = new Batman.Renderer(node, =>
yieldTo = @contentFor
if typeof yieldTo is 'string'
@contentFor = Batman.DOM._yields[yieldTo]
if @contentFor and node
$setInnerHTML @contentFor, ''
@contentFor.appendChild(node)
else if yieldTo
if contents = Batman.DOM._yieldContents[yieldTo]
contents.push node
else
Batman.DOM._yieldContents[yieldTo] = [node]
, @contexts)
@_renderer.on 'rendered', => @fire('ready', node) |
DOM Helpers | |
| class Batman.Renderer extends Batman.Object
constructor: (@node, callback, contexts = []) ->
super()
@on('parsed', callback) if callback?
@context = if contexts instanceof Batman.RenderContext then contexts else Batman.RenderContext.start(contexts...)
@timeout = setTimeout @start, 0
start: =>
@startTime = new Date
@parseNode @node
resume: =>
@startTime = new Date
@parseNode @resumeNode
finish: ->
@startTime = null
@fire 'parsed'
@fire 'rendered'
stop: ->
clearTimeout(@timeout)
@fire 'stopped'
forgetAll: ->
for k in ['parsed', 'rendered', 'stopped']
@::event(k).oneShot = true
bindingRegexp = /^data\-(.*)/
sortBindings = (a, b) ->
if a[0] == 'foreach'
-1
else if b[0] == 'foreach'
1
else if a[0] == 'formfor'
-1
else if b[0] == 'formfor'
1
else if a[0] == 'bind'
-1
else if b[0] == 'bind'
1
else
0
parseNode: (node) ->
if new Date - @startTime > 50
@resumeNode = node
@timeout = setTimeout @resume, 0
return
if node.getAttribute and node.attributes
bindings = for attr in node.attributes
name = attr.nodeName.match(bindingRegexp)?[1]
continue if not name
if ~(varIndex = name.indexOf('-'))
[name.substr(0, varIndex), name.substr(varIndex + 1), attr.value]
else
[name, attr.value]
for readerArgs in bindings.sort(sortBindings)
key = readerArgs[1]
result = if readerArgs.length == 2
Batman.DOM.readers[readerArgs[0]]?(node, key, @context, @)
else
Batman.DOM.attrReaders[readerArgs[0]]?(node, key, readerArgs[2], @context, @)
if result is false
skipChildren = true
break
else if result instanceof Batman.RenderContext
@context = result
if (nextNode = @nextNode(node, skipChildren)) then @parseNode(nextNode) else @finish()
nextNode: (node, skipChildren) ->
if not skipChildren
children = node.childNodes
return children[0] if children?.length
Batman.data(node, 'onParseExit')?()
return if @node == node
sibling = node.nextSibling
return sibling if sibling
nextParent = node
while nextParent = nextParent.parentNode
nextParent.onParseExit?()
return if @node == nextParent
parentSibling = nextParent.nextSibling
return parentSibling if parentSibling
return |
Bindings are shortlived objects which manage the observation of any keypaths a | class Binding extends Batman.Object |
A beastly regular expression for pulling keypaths out of the JSON arguments to a filter. It makes the following matches:
| keypath_rx = ///
(^|,) # Match either the start of an arguments list or the start of a space inbetween commas.
\s* # Be insensitive to whitespace between the comma and the actual arguments.
(?! # Use a lookahead to ensure we aren't matching true or false:
(?:true|false) # Match either true or false ...
\s* # and make sure that there's nothing else that comes after the true or false ...
(?:$|,) # before the end of this argument in the list.
)
([a-zA-Z][\w\.]*) # Now that true and false can't be matched, match a dot delimited list of keys.
\s* # Be insensitive to whitespace before the next comma or end of the filter arguments list.
($|,) # Match either the next comma or the end of the filter arguments list.
///g |
A less beastly pair of regular expressions for pulling out the [] syntax | get_dot_rx = /(?:\]\.)(.+?)(?=[\[\.]|\s*\||$)/
get_rx = /(?!^\s*)\[(.*?)\]/g
deProxy = (object) -> if object instanceof Batman.RenderContext.ContextProxy then object.get('proxiedObject') else object |
The | @accessor 'filteredValue', ->
unfilteredValue = @get('unfilteredValue')
if @filterFunctions.length > 0
developer.currentFilterStack = @renderContext
result = @filterFunctions.reduce((value, fn, i) => |
Get any argument keypaths from the context stored at parse time. | args = @filterArguments[i].map (argument) ->
if argument._keypath
argument.context.get(argument._keypath)
else
argument |
Apply the filter. | args.unshift value
args = args.map deProxy
fn.apply(@renderContext, args)
, unfilteredValue)
developer.currentFilterStack = null
result
else
deProxy(unfilteredValue) |
The | @accessor 'unfilteredValue', -> |
If we're working with an | if k = @get('key')
@get("keyContext.#{k}")
else
@get('value') |
The | @accessor 'keyContext', -> @renderContext.findKey(@key)[1]
constructor: ->
super |
Pull out the key and filter from the | @parseFilter()
if @node |
Tie this binding to its node using Batman.data | if bindings = Batman.data @node, 'bindings'
bindings.add @
else
Batman.data @node, 'bindings', new Batman.Set @ |
Define the default observers. | @nodeChange ||= (node, context) =>
if @key && @filterFunctions.length == 0
@get('keyContext').set @key, @node.value
@dataChange ||= (value, node) ->
Batman.DOM.valueForNode @node, value
shouldSet = yes |
And attach them. | if @only in [false, 'nodeChange'] and Batman.DOM.nodeIsEditable(@node)
Batman.DOM.events.change @node, =>
shouldSet = no
@nodeChange(@node, @get('keyContext') || @value, @)
shouldSet = yes |
Observe the value of this binding's | if @only in [false, 'dataChange']
@observeAndFire 'filteredValue', (value) =>
if shouldSet
@dataChange(value, @node, @)
@
parseFilter: -> |
Store the function which does the filtering and the arguments (all except the actual value to apply the filter to) in these arrays. | @filterFunctions = []
@filterArguments = [] |
Rewrite [] style gets, replace quotes to be JSON friendly, and split the string by pipes to see if there are any filters. | keyPath = @keyPath
keyPath = keyPath.replace(get_dot_rx, "]['$1']") while get_dot_rx.test(keyPath) # Stupid lack of lookbehind assertions...
filters = keyPath.replace(get_rx, " | get $1 ").replace(/'/g, '"').split(/(?!")\s+\|\s+(?!")/) |
The key will is always the first token before the pipe. | try
key = @parseSegment(orig = filters.shift())[0]
catch e
developer.warn e
developer.error "Error! Couldn't parse keypath in \"#{orig}\". Parsing error above."
if key and key._keypath
@key = key._keypath
else
@value = key
if filters.length
while filterString = filters.shift() |
For each filter, get the name and the arguments by splitting on the first space. | split = filterString.indexOf(' ')
if ~split
filterName = filterString.substr(0, split)
args = filterString.substr(split)
else
filterName = filterString |
If the filter exists, grab it. | if filter = Batman.Filters[filterName]
@filterFunctions.push filter |
Get the arguments for the filter by parsing the args as JSON, or just pushing an placeholder array | if args
try
@filterArguments.push @parseSegment(args)
catch e
developer.error "Bad filter arguments \"#{args}\"!"
else
@filterArguments.push []
else
developer.error "Unrecognized filter '#{filterName}' in key \"#{@keyPath}\"!" |
Map over each array of arguments to grab the context for any keypaths. | @filterArguments = @filterArguments.map (argumentList) =>
argumentList.map (argument) =>
if argument._keypath |
Discard the value (for the time being) and store the context for the keypath in | [_, argument.context] = @renderContext.findKey argument._keypath
argument |
Turn a piece of a | parseSegment: (segment) ->
JSON.parse( "[" + segment.replace(keypath_rx, "$1{\"_keypath\": \"$2\"}$3") + "]" ) |
The RenderContext class manages the stack of contexts accessible to a view during rendering. Every, and I really mean every method which uses filters has to be defined in terms of a new binding, or by using the RenderContext.bind method. This is so that the proper order of objects is traversed and any observers are properly attached. | class Batman.RenderContext
@start: (contexts...) ->
node = new @(window)
contexts.push Batman.currentApp if Batman.currentApp
while context = contexts.pop()
node = node.descend(context)
node
constructor: (@object, @parent) ->
findKey: (key) ->
base = key.split('.')[0].split('|')[0].trim()
currentNode = @
while currentNode
if currentNode.object.get?
val = currentNode.object.get(base)
else
val = currentNode.object[base]
if typeof val isnt 'undefined' |
we need to pass the check if the basekey exists, even if the intermediary keys do not. | return [$get(currentNode.object, key), currentNode.object]
currentNode = currentNode.parent
return [container.get(key), container] |
Below are the three primitives that all the | descend: (object, scopedKey) ->
if scopedKey
oldObject = object
object = new Batman.Object()
object[scopedKey] = oldObject
return new @constructor(object, @) |
| descendWithKey: (key, scopedKey) ->
proxy = new ContextProxy(@, key)
return @descend(proxy, scopedKey) |
| bind: (node, key, dataChange, nodeChange, only = false) ->
return new Binding
renderContext: @
keyPath: key
node: node
dataChange: dataChange
nodeChange: nodeChange
only: only |
| chain: ->
x = []
parent = this
while parent
x.push parent.object
parent = parent.parent
x |
| @ContextProxy = class ContextProxy extends Batman.Object
isContextProxy: true |
Reveal the binding's final value. | @accessor 'proxiedObject', -> @binding.get('filteredValue') |
Proxy all gets to the proxied object. | @accessor
get: (key) -> @get("proxiedObject.#{key}")
set: (key, value) -> @set("proxiedObject.#{key}", value)
unset: (key) -> @unset("proxiedObject.#{key}")
constructor: (@renderContext, @keyPath, @localKey) ->
@binding = new Binding
renderContext: @renderContext
keyPath: @keyPath
only: 'neither'
Batman.DOM = { |
| readers: {
target: (node, key, context, renderer) ->
Batman.DOM.readers.bind(node, key, context, renderer, 'nodeChange')
true
source: (node, key, context, renderer) ->
Batman.DOM.readers.bind(node, key, context, renderer, 'dataChange')
true
bind: (node, key, context, renderer, only) ->
switch node.nodeName.toLowerCase()
when 'input'
switch node.getAttribute('type')
when 'checkbox'
return Batman.DOM.attrReaders.bind(node, 'checked', key, context, renderer, only)
when 'radio'
return Batman.DOM.binders.radio(arguments...)
when 'file'
return Batman.DOM.binders.file(arguments...)
when 'select'
return Batman.DOM.binders.select(arguments...) |
Fallback on the default nodeChange and dataChange observers in Binding | context.bind(node, key, undefined, undefined, only)
true
context: (node, key, context, renderer) -> return context.descendWithKey(key)
mixin: (node, key, context) ->
context.descend(Batman.mixins).bind(node, key, (mixin) ->
$mixin node, mixin
, ->)
true
showif: (node, key, context, renderer, invert) ->
originalDisplay = node.style.display || ''
context.bind(node, key, (value) ->
if !!value is !invert
Batman.data(node, 'show')?.call(node)
node.style.display = originalDisplay
else
hide = Batman.data node, 'hide'
if typeof hide == 'function'
hide.call node
else
node.style.display = 'none'
, -> )
true
hideif: (args...) ->
Batman.DOM.readers.showif args..., yes
true
route: (node, key, context) -> |
you must specify the / in front to route directly to hash route | if key.substr(0, 1) is '/'
url = key
else
isHash = key.indexOf('#') > 1
[key, action] = if isHash then key.split('#') else key.split('/')
[dispatcher, app] = context.findKey 'dispatcher'
[model, container] = context.findKey key if not isHash
dispatcher ||= Batman.currentApp.dispatcher
if isHash
url = dispatcher.findUrl controller: key, action: action
else if model instanceof Batman.Model
action ||= 'show'
name = helpers.underscore(helpers.pluralize($functionName(model.constructor)))
url = dispatcher.findUrl({resource: name, id: model.get('id'), action: action})
else if model?.prototype # TODO write test for else case
action ||= 'index'
name = helpers.underscore(helpers.pluralize($functionName(model)))
url = dispatcher.findUrl({resource: name, action: action})
return unless url
if node.nodeName.toUpperCase() is 'A'
node.href = Batman.HashHistory::urlFor url
Batman.DOM.events.click node, (-> $redirect url)
true
partial: (node, path, context, renderer) ->
renderer.prevent('rendered')
view = new Batman.View
source: path + '.html'
contentFor: node
contexts: context.chain()
view.on 'ready', ->
renderer.allow 'rendered'
renderer.fire 'rendered'
true
yield: (node, key) ->
setTimeout (-> Batman.DOM.yield key, node), 0
true
contentfor: (node, key) ->
setTimeout (-> Batman.DOM.contentFor key, node), 0
true
replace: (node, key) ->
setTimeout (-> Batman.DOM.replace key, node), 0
true
}
_yieldContents: {} # name/content pairs of content to be yielded
_yields: {} # name/container pairs of yielding nodes |
| attrReaders: {
_parseAttribute: (value) ->
if value is 'false' then value = false
if value is 'true' then value = true
value
source: (node, attr, key, context, renderer) ->
Batman.DOM.attrReaders.bind node, attr, key, context, renderer, 'dataChange'
bind: (node, attr, key, context, renderer, only) ->
switch attr
when 'checked', 'disabled', 'selected'
dataChange = (value) ->
node[attr] = !!value |
Update the parent's binding if necessary | Batman.data(node.parentNode, 'updateBinding')?()
nodeChange = (node, subContext) ->
subContext.set(key, Batman.DOM.attrReaders._parseAttribute(node[attr])) |
Make the context and key available to the parent select | Batman.data node, attr,
context: context
key: key
when 'value', 'href', 'src', 'size'
dataChange = (value) -> node[attr] = value
nodeChange = (node, subContext) -> subContext.set(key, Batman.DOM.attrReaders._parseAttribute(node[attr]))
when 'class'
dataChange = (value) -> node.className = value
nodeChange = (node, subContext) -> subContext.set key, node.className
when 'style'
return Batman.DOM.binders.style node, attr, key, context, renderer, only
else
dataChange = (value) -> node.setAttribute(attr, value)
nodeChange = (node, subContext) -> subContext.set(key, Batman.DOM.attrReaders._parseAttribute(node.getAttribute(attr)))
context.bind(node, key, dataChange, nodeChange, only)
true
context: (node, contextName, key, context) -> return context.descendWithKey(key, contextName)
event: (node, eventName, key, context) ->
props =
callback: null
subContext: null
context.bind node, key, (value, node, binding) ->
props.callback = value
if binding.get('key')
ks = binding.get('key').split('.')
ks.pop()
if ks.length > 0
props.subContext = binding.get('keyContext').get(ks.join('.'))
else
props.subContext = binding.get('keyContext')
, ->
confirmText = node.getAttribute('data-confirm')
Batman.DOM.events[eventName] node, ->
if confirmText and not confirm(confirmText)
return
props.callback?.apply props.subContext, arguments
true
addclass: (node, className, key, context, parentRenderer, invert) ->
className = className.replace(/\|/g, ' ') #this will let you add or remove multiple class names in one binding
context.bind node, key, (value) ->
currentName = node.className
includesClassName = currentName.indexOf(className) isnt -1
if !!value is !invert
node.className = "#{currentName} #{className}" if !includesClassName
else
node.className = currentName.replace(className, '') if includesClassName
, ->
true
removeclass: (args...) -> Batman.DOM.attrReaders.addclass args..., yes
foreach: (node, iteratorName, key, context, parentRenderer) ->
new Batman.DOM.Iterator(arguments...)
false # Return false so the Renderer doesn't descend into this node's children.
formfor: (node, localName, key, context) ->
Batman.DOM.events.submit node, (node, e) -> $preventDefault e
context.descendWithKey(key, localName)
} |
| binders: {
select: (node, key, context, renderer, only) ->
[boundValue, container] = context.findKey key
updateSelectBinding = => |
Gather the selected options and update the binding | selections = if node.multiple then (c.value for c in node.children when c.selected) else node.value
selections = selections[0] if selections.length == 1
container.set key, selections
updateOptionBindings = => |
Go through the option nodes and update their bindings using the context and key attached to the node via Batman.data | for child in node.children
if data = Batman.data(child, 'selected')
if (subContext = data.context) and (subKey = data.key)
[subBoundValue, subContainer] = subContext.findKey subKey
unless child.selected == subBoundValue
subContainer.set subKey, child.selected |
wait for the select to render before binding to it | renderer.on 'rendered', -> |
Update the select box with the binding's new value. | dataChange = (newValue) -> |
For multi-select boxes, the | if newValue instanceof Array |
Use a hash to map values to their nodes to avoid O(n^2). | valueToChild = {}
for child in node.children |
Clear all options. | child.selected = false |
Avoid collisions among options with same values. | matches = valueToChild[child.value]
if matches then matches.push child else matches = [child]
valueToChild[child.value] = matches |
Select options corresponding to the new values | for value in newValue
for match in valueToChild[value]
match.selected = yes |
For a regular select box, we just update the value. | else
node.value = newValue |
Finally, we need to update the options' | updateOptionBindings() |
Update the bindings with the node's new value | nodeChange = ->
updateSelectBinding()
updateOptionBindings() |
Expose the updateSelectBinding helper for the child options | Batman.data node, 'updateBinding', updateSelectBinding |
Create the binding | context.bind node, key, dataChange, nodeChange, only
true
style: (node, attr, key, context, renderer, only) ->
new Batman.DOM.Style node, key, context
true
radio: (node, key, context, renderer, only) ->
dataChange = (value) -> |
don't overwrite | [boundValue, container] = context.findKey key
if boundValue
node.checked = boundValue == node.value
else if node.checked
container.set key, node.value
nodeChange = (newNode, subContext) ->
subContext.set(key, Batman.DOM.attrReaders._parseAttribute(node.value))
context.bind node, key, dataChange, nodeChange, only
true
file: (node, key, context, renderer, only) ->
context.bind(node, key, ->
developer.warn "Can't write to file inputs! Tried to on key #{key}."
, (node, subContext) ->
if subContext instanceof Batman.RenderContext.ContextProxy
actualObject = subContext.get('proxiedObject')
else
actualObject = subContext
if actualObject.hasStorage && actualObject.hasStorage()
for adapter in actualObject._batman.get('storage') when adapter instanceof Batman.RestStorage
adapter.defaultOptions.formData = true
if node.hasAttribute('multiple')
subContext.set key, Array::slice.call(node.files)
else
subContext.set key, node.files[0]
, only)
true
} |
| events: {
click: (node, callback, eventName = 'click') ->
$addEventListener node, eventName, (args...) ->
callback node, args...
$preventDefault args[0]
if node.nodeName.toUpperCase() is 'A' and not node.href
node.href = '#'
node
doubleclick: (node, callback) -> |
The actual DOM event is called | Batman.DOM.events.click node, callback, 'dblclick'
change: (node, callback) ->
eventNames = switch node.nodeName.toUpperCase()
when 'TEXTAREA' then ['keyup', 'change']
when 'INPUT'
if node.type.toUpperCase() is 'TEXT'
oldCallback = callback
callback = (e) ->
return if e.type == 'keyup' && 13 <= e.keyCode <= 14
oldCallback(arguments...)
['keyup', 'change']
else
['change']
else ['change']
for eventName in eventNames
$addEventListener node, eventName, (args...) ->
callback node, args...
submit: (node, callback) ->
if Batman.DOM.nodeIsEditable(node)
$addEventListener node, 'keyup', (args...) ->
if args[0].keyCode is 13 || args[0].which is 13 || args[0].keyIdentifier is 'Enter' || args[0].key is 'Enter'
$preventDefault args[0]
callback node, args...
else
$addEventListener node, 'submit', (args...) ->
$preventDefault args[0]
callback node, args...
node
} |
| yield: (name, node, _replaceContent = !Batman.data(node, 'yielded')) ->
Batman.DOM._yields[name] = node |
render any content for this yield | if contents = Batman.DOM._yieldContents[name]
if _replaceContent
$setInnerHTML node, ''
for content in contents when !Batman.data(content, 'yielded')
content = if $isChildOf(node, content) then content.cloneNode(true) else content
node.appendChild(content)
Batman.data(content, 'yielded', true) |
delete references to the rendered content nodes and mark the node as yielded | delete Batman.DOM._yieldContents[name]
Batman.data(node, 'yielded', true)
contentFor: (name, node, _replaceContent) ->
contents = Batman.DOM._yieldContents[name]
if contents then contents.push(node) else Batman.DOM._yieldContents[name] = [node]
if yieldingNode = Batman.DOM._yields[name]
Batman.DOM.yield name, yieldingNode, _replaceContent
replace: (name, node) ->
Batman.DOM.contentFor name, node, true |
Removes listeners and bindings tied to | unbindNode: $unbindNode = (node) -> |
remove all event listeners | if listeners = Batman.data node, 'listeners'
for eventName, eventListeners of listeners
eventListeners.forEach (listener) ->
$removeEventListener node, eventName, listener |
remove all bindings and other data associated with this node | Batman.removeData node |
Unbinds the tree rooted at | unbindTree: $unbindTree = (node, unbindRoot = true) ->
return unless node?.nodeType is 1
$unbindNode node if unbindRoot
$unbindTree(child) for child in node.childNodes |
Memory-safe setting of a node's innerHTML property | setInnerHTML: $setInnerHTML = (node, html) ->
$unbindTree node, false
node?.innerHTML = html |
Memory-safe removal of a node from the DOM | removeNode: $removeNode = (node) ->
$unbindTree node
node?.parentNode?.removeChild node
valueForNode: (node, value = '') ->
isSetting = arguments.length > 1
switch node.nodeName.toUpperCase()
when 'INPUT'
if isSetting then (node.value = value) else node.value
when 'TEXTAREA'
if isSetting
node.innerHTML = node.value = value
else
node.innerHTML
when 'SELECT'
node.value = value
else
if isSetting
$setInnerHTML node, value
else node.innerHTML
nodeIsEditable: (node) ->
node.nodeName.toUpperCase() in ['INPUT', 'TEXTAREA', 'SELECT'] |
`$addEventListener uses attachEvent when necessary | addEventListener: $addEventListener = (node, eventName, callback) -> |
store the listener in Batman.data | unless listeners = Batman.data node, 'listeners'
listeners = Batman.data node, 'listeners', {}
unless listeners[eventName]
listeners[eventName] = new Batman.Set
listeners[eventName].add callback
if $hasAddEventListener
node.addEventListener eventName, callback, false
else
node.attachEvent "on#{eventName}", callback |
| removeEventListener: $removeEventListener = (node, eventName, callback) -> |
remove the listener from Batman.data | if listeners = Batman.data node, 'listeners'
if eventListeners = listeners[eventName]
eventListeners.remove callback
if $hasAddEventListener
node.removeEventListener eventName, callback, false
else
node.detachEvent 'on'+eventName, callback
hasAddEventListener: $hasAddEventListener = !!window?.addEventListener
}
class Batman.DOM.Style
constructor: (@node, @key, @context) ->
@oldStyles = {}
context.bind node, key, @dataChange, ->
dataChange: (value) =>
unless value
@reapplyOldStyles()
return
if typeof value is 'string' and @boundValueType = 'string'
@reapplyOldStyles()
for style in value.split(';')
[cssName, cssValue] = style.split(':')
@setStyle cssName, cssValue
return
if value instanceof Batman.Hash and @boundValueType = 'batman.hash' |
remove listeners from a previously bound hash | if @styleHash
@styleHash.event('itemsWereRemoved').removeHandler(@onItemsRemoved)
@styleHash.event('itemsWereAdded').removeHandler(@onItemsAdded)
@styleHash = value |
attach listeners to the the new hash | value.on 'itemsWereAdded', @onItemsAdded
value.on 'itemsWereRemoved', @onItemsRemoved |
set styles | value.keys().forEach (key) => @onItemsAdded(key)
else if value instanceof Object and @boundValueType = 'object'
@reapplyOldStyles()
for own key, keyValue of value |
Check whether the value is an existing keypath, and if so bind this attribute to it | [keypathValue, keypathContext] = @context.findKey(keyValue)
if keypathValue
@bindSingleAttribute key, keyValue
@setStyle key, keypathValue
else
@setStyle key, keyValue
onItemsAdded: (newKey) => @setStyle newKey, @styleHash.get(newKey)
onItemsRemoved: (oldKey) => @setStyle oldKey, ''
bindSingleAttribute: (attr, keypath) =>
dataChange = (value) => @setStyle attr, value
@context.bind @node, keypath, dataChange, ->
setStyle: (key, value) =>
return unless key
key = key.trim()
@oldStyles[key] = @node.style[key]
@node.style[key] = if value then value.trim() else ""
reapplyOldStyles: =>
@setStyle(cssName, cssValue) for own cssName, cssValue of @oldStyles
class Batman.DOM.Iterator
currentAddNumber: 0
queuedAddNumber: 0
constructor: (sourceNode, @iteratorName, @key, @context, @parentRenderer) ->
@nodeMap = new Batman.SimpleHash
@rendererMap = new Batman.SimpleHash
@prototypeNode = sourceNode.cloneNode(true)
@prototypeNode.removeAttribute "data-foreach-#{iteratorName}"
@parentNode = sourceNode.parentNode
@siblingNode = sourceNode.nextSibling |
Remove the original node once the parent has moved past it. | @parentRenderer.on 'parsed', -> $removeNode sourceNode
@addFunctions = []
@fragment = document.createDocumentFragment()
context.bind(sourceNode, key, @collectionChange, ->)
collectionChange: (newCollection) => |
Deal with any nodes inserted by previous collections | if @collection
return if newCollection == @collection
@nodeMap.forEach (item, node) -> $removeNode node
@nodeMap.clear()
@rendererMap.forEach (item, renderer) -> renderer.stop()
@rendererMap.clear()
if @collection.isObservble && @collection.toArray
@collection.forget(@arrayChanged)
else if @collection.isEventEmitter
@collection.event('itemsWereAdded').removeHandler(@currentAddNumber)
@collection.event('itemsWereRemoved').removeHandler(@currentRemovedHandler)
@collection = newCollection
if @collection
if @collection.isObservable && @collection.toArray
@collection.observe 'toArray', @arrayChanged
else if @collection.isEventEmitter
@collection.on 'itemsWereAdded', @currentAddedHandler = (items...) =>
@addItem(item, {fragment: true, addNumber: @currentAddFunction + i}) for item, i in items
@collection.on 'itemsWereRemoved', @currentRemovedHandler = (items...) =>
@removeItem(item) for item, i in items
if @collection.toArray
@arrayChanged()
else if @collection.forEach
@collection.forEach (item) => @addItem(item)
else
@addItem(key) for own key, value of @collection
else
developer.warn "Warning! data-foreach-#{@iteratorName} called with an undefined binding. Key was: #{@key}."
addItem: (item, options = {fragment: true}) ->
options.addNumber = @queuedAddNumber++
@parentRenderer.prevent 'rendered'
finish = =>
@parentRenderer.allow 'rendered'
@parentRenderer.fire 'rendered'
self = @
childRenderer = new Batman.Renderer @_nodeForItem(item),
(-> self.insertItem(item, @node, options)),
@context.descend(item, @iteratorName)
@rendererMap.set(item, childRenderer)
childRenderer.on 'rendered', finish
childRenderer.on 'stopped', =>
@addFunctions[options.addNumber] = ->
@_processAddQueue()
finish()
removeItem: (item) ->
oldNode = @nodeMap.unset(item)
if oldNode
if hideFunction = Batman.data oldNode, 'hide'
hideFunction.call(oldNode)
else
$removeNode(oldNode)
arrayChanged: =>
newItemsInOrder = @collection.toArray()
trackingNodeMap = new Batman.SimpleHash
for item in newItemsInOrder
existingNode = @nodeMap.get(item)
trackingNodeMap.set(item, true)
if existingNode
@insertItem(item, existingNode, {fragment: false, addNumber: @queuedAddNumber++, sync: true})
else
@addItem(item, {fragment: false})
@nodeMap.forEach (item, node) =>
@removeItem(item) unless trackingNodeMap.hasKey(item)
insertItem: (item, node, options = {}) ->
if @nodeMap.get(item) != node |
The render has rendered a node which is now out of date, do nothing. | @addFunctions[options.addNumber] = ->
else
@rendererMap.unset item
@addFunctions[options.addNumber] = ->
show = Batman.data node, 'show'
if typeof show is 'function'
show.call node, before: @siblingNode
else
if options.fragment
@fragment.appendChild node
else
@parentNode.insertBefore node, @siblingNode
@_processAddQueue()
_nodeForItem: (item) ->
newNode = @prototypeNode.cloneNode(true)
@nodeMap.set(item, newNode)
newNode
_processAddQueue: ->
while !!(f = @addFunctions[@currentAddNumber])
@addFunctions[@currentAddNumber] = undefined
f.call(@)
@currentAddNumber++
if @fragment && @rendererMap.length is 0 && @fragment.hasChildNodes()
@parentNode.insertBefore @fragment, @siblingNode
@fragment = document.createDocumentFragment()
return |
Filters
| buntUndefined = (f) ->
(value) ->
if typeof value is 'undefined'
undefined
else
f.apply(@, arguments)
filters = Batman.Filters =
get: buntUndefined (value, key) ->
if value.get?
value.get(key)
else
value[key]
equals: buntUndefined (lhs, rhs) ->
lhs is rhs
not: (value) ->
! !!value
truncate: buntUndefined (value, length, end = "...") ->
if value.length > length
value = value.substr(0, length-end.length) + end
value
default: (value, string) ->
value || string
prepend: (value, string) ->
string + value
append: (value, string) ->
value + string
downcase: buntUndefined (value) ->
value.toLowerCase()
upcase: buntUndefined (value) ->
value.toUpperCase()
pluralize: buntUndefined (string, count) -> helpers.pluralize(count, string)
join: buntUndefined (value, byWhat = '') ->
value.join(byWhat)
sort: buntUndefined (value) ->
value.sort()
map: buntUndefined (value, key) ->
value.map((x) -> x[key])
first: buntUndefined (value) ->
value[0]
meta: buntUndefined (value, keypath) ->
developer.assert value.meta, "Error, value doesn't have a meta to filter on!"
value.meta.get(keypath)
for k in ['capitalize', 'singularize', 'underscore', 'camelize']
filters[k] = buntUndefined helpers[k]
developer.addFilters() |
Data | $mixin Batman,
cache: {}
uuid: 0
expando: "batman" + Math.random().toString().replace(/\D/g, '')
canDeleteExpando: true
noData: # these throw exceptions if you attempt to add expandos to them
"embed": true, |
Ban all objects except for Flash (which handle expandos) | "object": "clsid:D27CDB6E-AE6D-11cf-96B8-444553540000",
"applet": true
hasData: (elem) ->
elem = (if elem.nodeType then Batman.cache[elem[Batman.expando]] else elem[Batman.expando])
!!elem and !isEmptyDataObject(elem)
data: (elem, name, data, pvt) -> # pvt is for internal use only
return unless Batman.acceptData(elem)
internalKey = Batman.expando
getByName = typeof name == "string" |
DOM nodes and JS objects have to be handled differently because IE6-7 can't GC object references properly across the DOM-JS boundary | isNode = elem.nodeType |
Only DOM nodes need the global cache; JS object data is attached directly so GC can occur automatically | cache = if isNode then Batman.cache else elem |
Only defining an ID for JS objects if its cache already exists allows the code to shortcut on the same path as a DOM node with no cache | id = if isNode then elem[Batman.expando] else elem[Batman.expando] && Batman.expando |
Avoid doing any more work than we need to when trying to get data on an object that has no data at all | if (not id or (pvt and id and (cache[id] and not cache[id][internalKey]))) and getByName and data == undefined
return
unless id |
Only DOM nodes need a new unique ID for each element since their data ends up in the global cache | if isNode
elem[Batman.expando] = id = ++Batman.uuid
else
id = Batman.expando
cache[id] = {} unless cache[id] |
An object can be passed to Batman.data instead of a key/value pair; this gets shallow copied over onto the existing cache | if typeof name == "object" or typeof name == "function"
if pvt
cache[id][internalKey] = $mixin(cache[id][internalKey], name)
else
cache[id] = $mixin(cache[id], name)
thisCache = cache[id] |
Internal Batman data is stored in a separate object inside the object's data cache in order to avoid key collisions between internal data and user-defined data | if pvt
thisCache[internalKey] = {} unless thisCache[internalKey]
thisCache = thisCache[internalKey]
unless data is undefined
thisCache[helpers.camelize(name, true)] = data |
Check for both converted-to-camel and non-converted data property names If a data property was specified | if getByName |
First try to find as-is property data | ret = thisCache[name] |
Test for null|undefined property data and try to find camel-cased property | ret = thisCache[helpers.camelize(name, true)] unless ret?
else
ret = thisCache
return ret
removeData: (elem, name, pvt) -> # pvt is for internal use only
return unless Batman.acceptData(elem)
internalKey = Batman.expando
isNode = elem.nodeType |
non DOM-nodes have their data attached directly | cache = if isNode then Batman.cache else elem
id = if isNode then elem[Batman.expando] else Batman.expando |
If there is already no cache entry for this object, there is no purpose in continuing | return unless cache[id]
if name
thisCache = if pvt then cache[id][internalKey] else cache[id]
if thisCache |
Support interoperable removal of hyphenated or camelcased keys | name = helpers.camelize(name, true) unless thisCache[name]
delete thisCache[name] |
If there is no data left in the cache, we want to continue and let the cache object itself get destroyed | return unless isEmptyDataObject(thisCache)
if pvt
delete cache[id][internalKey] |
Don't destroy the parent cache unless the internal data object had been the only thing left in it | return unless isEmptyDataObject(cache[id])
internalCache = cache[id][internalKey] |
Browsers that fail expando deletion also refuse to delete expandos on
the window, but it will allow it on all other JS objects; other browsers
don't care
Ensure that | if Batman.canDeleteExpando or !cache.setInterval
delete cache[id]
else
cache[id] = null |
We destroyed the entire user cache at once because it's faster than iterating through each key, but we need to continue to persist internal data if it existed | if internalCache
cache[id] = {}
cache[id][internalKey] = internalCache |
Otherwise, we need to eliminate the expando on the node to avoid false lookups in the cache for entries that no longer exist | else if isNode
if Batman.canDeleteExpando
delete elem[Batman.expando]
else if elem.removeAttribute
elem.removeAttribute Batman.expando
else
elem[Batman.expando] = null |
For internal use only | _data: (elem, name, data) ->
Batman.data elem, name, data, true |
A method for determining if a DOM node can handle the data expando | acceptData: (elem) ->
if elem.nodeName
match = Batman.noData[elem.nodeName.toLowerCase()]
if match
return !(match == true or elem.getAttribute("classid") != match)
return true
isEmptyDataObject = (obj) ->
for name of obj
return false
return true |
Test to see if it's possible to delete an expando from an element Fails in Internet Explorer | try
div = document.createElement 'div'
delete div.test
catch e
Batman.canDeleteExpando = false |
Mixins | mixins = Batman.mixins = new Batman.Object() |
Encoders | Batman.Encoders =
railsDate:
encode: (value) -> value
decode: (value) ->
a = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value)
if a
return new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4], +a[5], +a[6]))
else
developer.error "Unrecognized rails date #{value}!" |
Export a few globals, and grab a reference to an object accessible from all contexts for use elsewhere.
In node, the container is the | container = if exports?
module.exports = Batman
global
else
window.Batman = Batman
window
$mixin container, Batman.Observable |
Optionally export global sugar. Not sure what to do with this. | Batman.exportHelpers = (onto) ->
for k in ['mixin', 'unmixin', 'route', 'redirect', 'typeOf', 'redirect']
onto["$#{k}"] = Batman[k]
onto
Batman.exportGlobals = () ->
Batman.exportHelpers(container)
|