batman.coffee | |
---|---|
batman.js Created by Nick Small Copyright 2011, Shopify | |
The global namespace, the | Batman = (mixins...) ->
new Batman.Object mixins...
Batman.version = '0.8.0'
Batman.config =
pathPrefix: '/'
usePushState: no |
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 |
| 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
$setImmediate = $clearImmediate = null
_implementImmediates = (container) ->
canUsePostMessage = ->
return false unless container.postMessage
async = true
oldMessage = container.onmessage
container.onmessage = -> async = false
container.postMessage("","*")
container.onmessage = oldMessage
async
tasks = new Batman.SimpleHash
count = 0
getHandle = -> "go#{++count}"
if container.setImmediate
$setImmediate = container.setImmediate
$clearImmediate = container.clearImmediate
else if container.msSetImmediate
$setImmediate = msSetImmediate
$clearImmediate = msClearImmediate
else if canUsePostMessage()
prefix = 'com.batman.'
functions = new Batman.SimpleHash
handler = (e) ->
return unless ~e.data.search(prefix)
handle = e.data.substring(prefix.length)
tasks.unset(handle)?()
if container.addEventListener
container.addEventListener('message', handler, false)
else
container.attachEvent('onmessage', handler)
$setImmediate = (f) ->
tasks.set(handle = getHandle(), f)
container.postMessage(prefix+handle, "*")
handle
$clearImmediate = (handle) -> tasks.unset(handle)
else if typeof document isnt 'undefined' && "onreadystatechange" in document.createElement("script")
$setImmediate = (f) ->
handle = getHandle()
script = document.createElement("script")
script.onreadystatechange = ->
tasks.get(handle)?()
script.onreadystatechange = null
script.parentNode.removeChild(script)
script = null
document.documentElement.appendChild(script)
handle
$clearImmediate = (handle) -> tasks.unset(handle)
else
$setImmediate = (f) -> setTimeout(f, 0)
$clearImmediate = (handle) -> clearTimeout(handle)
Batman.setImmediate = $setImmediate
Batman.clearImmediate = $clearImmediate
Batman.setImmediate = $setImmediate = ->
_implementImmediates(Batman.container)
Batman.setImmediate.apply(@, arguments)
Batman.clearImmediate = $clearImmediate = ->
_implementImmediates(Batman.container)
Batman.clearImmediate.apply(@, arguments)
Batman.forEach = $forEach = (container, iterator, ctx) ->
if container.forEach
container.forEach(iterator, ctx)
else if container.indexOf
iterator.call(ctx, e, i, container) for e,i in container
else
iterator.call(ctx, k, v, container) for k,v of container
Batman.objectHasKey = $objectHasKey = (object, key) ->
if typeof object.hasKey is 'function'
object.hasKey(key)
else
key of object
Batman.contains = $contains = (container, item) ->
if container.indexOf
item in container
else if typeof container.has is 'function'
container.has(item)
else
$objectHasKey(container, item)
Batman.get = $get = (base, key) ->
if typeof base.get is 'function'
base.get(key)
else
Batman.Property.forBaseAndKey(base, key).getValue() |
| 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() unless developer.suppressed
addFilters: ->
$mixin Batman.Filters,
log: (value, key) ->
console?.log? arguments
value
logStack: (value) ->
console?.log? developer.currentFilterStack
value
Batman.developer = developer
developer.assert (->).bind, "Error! Batman needs Function.bind to work! Please shim it using something like es5-shim or augmentjs!" |
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 and ancestor.hasEvent(key)
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)
allowAndFire: ->
@allow()
@fire(arguments...)
Batman.EventEmitter =
isEventEmitter: true
hasEvent: (key) ->
@_batman?.get?('events')?.hasKey(key)
event: (key) ->
Batman.initializeObject @
eventClass = @eventClass or Batman.Event
events = @_batman.events ||= new Batman.SimpleHash
if events.hasKey(key)
existingEvent = events.get(key)
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...)
allowAndFire: (key, args...) ->
@event(key).allowAndFire(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
cachable: no
@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) ->
developer.do =>
keyType = $typeOf(@key)
if keyType in ['Array', 'Object']
developer.log "Accessing a property with an #{keyType} key. This is okay, but could be a source of memory leaks if you aren't careful."
_isolationCount: 0
cached: no
value: null
sources: null
isProperty: true
isDead: false
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 and ancestor.hasProperty(key)
property = ancestor.property(key)
handlers = property.changeEvent().handlers
handlers.forEach(iterator)
observers: ->
results = []
@eachObserver (observer) -> results.push(observer)
results
hasObservers: -> @observers().length > 0
pushSourceTracker: -> Batman.Property._sourceTrackerStack.push(new Batman.SimpleSet)
pushDummySourceTracker: -> Batman.Property._sourceTrackerStack.push(null)
popSourceTracker: -> Batman.Property._sourceTrackerStack.pop()
updateSourcesFromTracker: ->
newSources = @popSourceTracker()
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 @isCached()
@pushSourceTracker()
try
@value = @valueFromAccessor()
@cached = yes
finally
@updateSourcesFromTracker()
@value
isCachable: ->
return true if @isFinal()
cachable = @accessor().cachable
if cachable? then !!cachable else true
isCached: -> @isCachable() and @cached
isFinal: -> !!@accessor().final
refresh: ->
@cached = no
previousValue = @value
value = @getValue()
if value isnt previousValue and not @isIsolated()
@fire(value, previousValue)
@lockValue() if @value isnt undefined and @isFinal()
sourceChangeHandler: ->
handler = => @_handleSourceChange()
@sourceChangeHandler = -> handler
handler
_handleSourceChange: ->
if @isIsolated()
@_needsRefresh = yes
else if not @isFinal() && not @hasObservers()
@cached = no
else
@refresh()
valueFromAccessor: -> @accessor().get?.call(@base, @key)
setValue: (val) ->
return unless set = @accessor().set
@_changeValue -> set.call(@base, @key, val)
unsetValue: ->
return unless unset = @accessor().unset
@_changeValue -> unset.call(@base, @key)
_changeValue: (block) ->
@cached = no
@pushDummySourceTracker()
try
result = block.apply(this)
@refresh()
finally
@popSourceTracker()
@die() unless @isCached() or @hasObservers()
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() unless @sources?
this
_removeHandlers: ->
handler = @sourceChangeHandler()
@_eachSourceChangeEvent (e) -> e.removeHandler(handler)
delete @sources
@changeEvent().handlers.clear()
lockValue: ->
@_removeHandlers()
@getValue = -> @value
@setValue = @unsetValue = @refresh = @observe = ->
die: ->
@_removeHandlers()
@base._batman?.properties?.unset(@key)
@isDead = true
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 = $get(base, segment)
propertyClass = base.propertyClass or Batman.Keypath
remainingSegments = @segments.slice(begin, end)
remainingPath = remainingSegments.join('.')
if propertyClass is Batman.Keypath or remainingSegments.length is 1
Batman.Keypath.forBaseAndKey(base, remainingPath)
else
new Batman.Keypath(base, remainingPath)
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
hasProperty: (key) ->
@_batman?.properties?.hasKey?(key)
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...)
@ |
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?
parent._batman?.check(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
Batman.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 not in ["_batman", "hashKey", "_objectID"]
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 = Batman.container) -> r = []; @forEach(-> r.push f.apply(ctx, arguments)); r
every: (f, ctx = Batman.container) -> r = true; @forEach(-> r = r && f.apply(ctx, arguments)); r
some: (f, ctx = Batman.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: (obj) ->
@_storage = {}
@length = 0
@update(obj) if obj?
$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) ->
hashKey = @hashKeyFor(key)
if pairs = @_storage[hashKey]
for [obj,value], index in pairs
if @equality(obj, key)
pair = pairs.splice(index,1)
delete @_storage[hashKey] unless pairs.length
@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, ctx) ->
for key, values of @_storage
iterator.call(ctx, obj, value, this) 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
update: (object) -> @set(k,v) for k,v of object
replace: (object) ->
@forEach (key, value) =>
@unset(key) unless key of object
@update(object)
toObject: ->
obj = {}
for key, pair of @_storage
obj[key] = pair[0][1] # the first value for this key
obj
toJSON: @::toObject
class Batman.Hash extends Batman.Object
class @Metadata extends Batman.Object
constructor: (@hash) ->
@accessor 'length', ->
@hash.registerAsMutableSource()
@hash.length
@accessor 'isEmpty', -> @hash.isEmpty()
@accessor 'keys', -> @hash.keys()
constructor: ->
@meta = new @constructor.Metadata(this)
Batman.SimpleHash.apply(@, arguments)
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
cachable: false
_preventMutationEvents: (block) ->
@prevent 'change'
@prevent 'itemsWereAdded'
@prevent 'itemsWereRemoved'
try
block.call(this)
finally
@allow 'change'
@allow 'itemsWereAdded'
@allow 'itemsWereRemoved'
clear: @mutation ->
keys = @keys()
@_preventMutationEvents -> @forEach (k) => @unset(k)
result = Batman.SimpleHash::clear.call(@)
@fire 'itemsWereRemoved', keys...
result
update: @mutation (object) ->
addedKeys = []
@_preventMutationEvents ->
Batman.forEach object, (k,v) =>
addedKeys.push(k) unless @hasKey(k)
@set(k,v)
@fire('itemsWereAdded', addedKeys...) if addedKeys.length > 0
replace: @mutation (object) ->
addedKeys = []
removedKeys = []
@_preventMutationEvents ->
@forEach (k, _) =>
unless Batman.objectHasKey(object, k)
@unset(k)
removedKeys.push(k)
Batman.forEach object, (k,v) =>
addedKeys.push(k) unless @hasKey(k)
@set(k,v)
@fire('itemsWereAdded', addedKeys...) if addedKeys.length > 0
@fire('itemsWereRemoved', removedKeys...) if removedKeys.length > 0
equality: Batman.SimpleHash::equality
hashKeyFor: Batman.SimpleHash::hashKeyFor
for k in ['hasKey', 'forEach', 'isEmpty', 'keys', 'merge', 'toJSON', 'toObject']
proto = @prototype
do (k) ->
proto[k] = ->
@registerAsMutableSource()
Batman.SimpleHash::[k].apply(@, arguments)
class Batman.SimpleSet
constructor: ->
@_storage = new Batman.SimpleHash
@_indexes = new Batman.SimpleHash
@_uniqueIndexes = 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, ctx) ->
container = this
@_storage.forEach (key) -> iterator.call(ctx, key, null, container)
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
replace: (other) ->
try
@prevent?('change')
@clear()
@add(other.toArray()...)
finally
@allowAndFire?('change', this, this)
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))
indexedByUnique: (key) ->
@_uniqueIndexes.get(key) or @_uniqueIndexes.set(key, new Batman.UniqueSetIndex(@, 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', 'replace', 'indexedBy', 'indexedByUnique', '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 'indexedByUnique', -> new Batman.TerminalAccessible (key) => @indexedByUnique(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
@accessor 'first', -> @toArray()[0]
@accessor 'last', -> @toArray()[@length - 1]
class Batman.SetObserver extends Batman.Object
constructor: (@base) ->
@_itemObservers = new Batman.SimpleHash
@_setObservers = new Batman.SimpleHash
@_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.SimpleHash
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', 'replace']
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, ctx) -> iterator.call(ctx,e,i,this) 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.SimpleHash
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)
super
if resultSet.isEmpty()
@_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
@accessor 'method', $mixin {}, Batman.Property.defaultAccessor,
set: (k,val) -> @[k] = val?.toUpperCase?() |
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 = $setImmediate => @send() |
| send: () -> developer.error "Please source a dependency file for a request implementation"
cancel: ->
clearTimeout(@_autosendTimeout) if @_autosendTimeout |
| class Batman.App extends Batman.Object
@classAccessor 'currentParams',
get: -> new Batman.Hash
final: true
@classAccessor 'paramsManager',
get: ->
return unless nav = @get('navigator')
params = @get('currentParams')
params.replacer = new Batman.ParamsReplacer(nav, params)
final: true
@classAccessor 'paramsPusher',
get: ->
return unless nav = @get('navigator')
params = @get('currentParams')
params.pusher = new Batman.ParamsPusher(nav, params)
final: true |
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('ready').oneShot = true
@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 @
@observe 'layout', (layout) =>
layout?.on 'ready', => @fire 'ready'
if typeof @layout is 'undefined'
@set 'layout', new Batman.View
context: @
node: document
else if typeof @layout is 'string'
@set 'layout', new @[helpers.camelize(@layout) + 'View']
if typeof @navigator is 'undefined' and @dispatcher.routeMap
@on 'run', =>
@set('navigator', Batman.navigator = Batman.Navigator.forApp(this)).start()
@hasRun = yes
@fire('run')
@
@event('ready').oneShot = true
@event('stop').oneShot = true
@stop: ->
@navigator?.stop()
Batman.navigator = 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) ->
params = @parameterize url
@dispatcher.app.get('currentParams').replace(params)
$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'
if typeof action is 'function'
matches = yes
else
{controller, action} = action
if controller is params.controller and action is (params.action || 'index')
matches = yes
continue if not matches
$mixin paramsCopy = {}, params
$unmixin paramsCopy, {controller:null, action:null, resource:null, url:null, signature:null, target:null}
for key, value of params
regex = new RegExp('[:|\*]' + key)
continue if not regex.test url
url = url.replace regex, value
paramsCopy[key] = null
delete paramsCopy[key]
queryString = ''
for key, value of paramsCopy
queryString += if not queryString then '?' else '&'
queryString += key + '=' + value
return url + queryString
pathFromParams: (params) ->
if $typeOf(params) is 'String'
Batman.Navigator.normalizePath(params)
else
@findUrl(params)
dispatch: (params) ->
url = @pathFromParams(params)
route = @findRoute(url)
if route
route.dispatch(url)
else
if $typeOf(params) is 'Object'
@app.get('currentParams').replace(params)
else
@app.get('currentParams').clear()
$redirect('/404') if url isnt '/404'
@app.set 'currentURL', url
@app.set 'currentRoute', route
url
class Batman.Navigator
@defaultClass: ->
if Batman.config.usePushState and Batman.PushStateNavigator.isSupported()
Batman.PushStateNavigator
else
Batman.HashbangNavigator
@forApp: (app) -> new (@defaultClass())(app)
constructor: (@app) ->
start: ->
return if typeof window is 'undefined'
return if @started
@started = yes
@startWatching()
Batman.currentApp.prevent 'ready'
$setImmediate =>
@handleCurrentLocation()
Batman.currentApp.allowAndFire 'ready'
stop: ->
@stopWatching()
@started = no
handleLocation: (location) ->
path = @pathFromLocation(location)
return if path is @cachedPath
@dispatch(path)
handleCurrentLocation: => @handleLocation(window.location)
dispatch: (params) ->
@cachedPath = @app.dispatcher.dispatch(params)
push: (params) ->
path = @dispatch(params)
@pushState(null, '', path)
path
replace: (params) ->
path = @dispatch(params)
@replaceState(null, '', path)
path
redirect: @::push
normalizePath: (segments...) ->
segments = for seg, i in segments
"#{seg}".replace(/^(?!\/)/, '/').replace(/\/+$/,'')
segments.join('') or '/'
@normalizePath: @::normalizePath
class Batman.PushStateNavigator extends Batman.Navigator
@isSupported: -> window?.history?.pushState?
startWatching: ->
$addEventListener window, 'popstate', @handleCurrentLocation
stopWatching: ->
$removeEventListener window, 'popstate', @handleCurrentLocation
pushState: (stateObject, title, path) ->
window.history.pushState(stateObject, title, @linkTo(path))
replaceState: (stateObject, title, path) ->
window.history.replaceState(stateObject, title, @linkTo(path))
linkTo: (url) ->
@normalizePath(Batman.config.pathPrefix, url)
pathFromLocation: (location) ->
fullPath = "#{location.pathname or ''}#{location.search or ''}"
prefixPattern = new RegExp("^#{@normalizePath(Batman.config.pathPrefix)}")
@normalizePath(fullPath.replace(prefixPattern, ''))
handleLocation: (location) ->
path = @pathFromLocation(location)
if path is '/' and (hashbangPath = Batman.HashbangNavigator::pathFromLocation(location)) isnt '/'
@replace(hashbangPath)
else
super
class Batman.HashbangNavigator extends Batman.Navigator
HASH_PREFIX: '#!'
if window? and 'onhashchange' of window
@::startWatching = ->
$addEventListener window, 'hashchange', @handleCurrentLocation
@::stopWatching = ->
$removeEventListener window, 'hashchange', @handleCurrentLocation
else
@::startWatching = ->
@interval = setInterval @handleCurrentLocation, 100
@::stopWatching = ->
@interval = clearInterval @interval
pushState: (stateObject, title, path) ->
window.location.hash = @linkTo(path)
replaceState: (stateObject, title, path) ->
loc = window.location
loc.replace("#{loc.pathname}#{loc.search}#{@linkTo(path)}")
linkTo: (url) -> @HASH_PREFIX + url
pathFromLocation: (location) ->
hash = location.hash
if hash?.substr(0,2) is @HASH_PREFIX
@normalizePath(hash.substr(2))
else
'/'
handleLocation: (location) ->
return super unless Batman.config.usePushState
realPath = Batman.PushStateNavigator::pathFromLocation(location)
if realPath is '/'
super
else
location.replace(@normalizePath("#{Batman.config.pathPrefix}#{@linkTo(realPath)}"))
Batman.redirect = $redirect = (url) ->
Batman.navigator?.redirect url
class Batman.ParamsReplacer extends Batman.Object
constructor: (@navigator, @params) ->
redirect: -> @navigator.replace(@toObject())
replace: (params) ->
@params.replace(params)
@redirect()
update: (params) ->
@params.update(params)
@redirect()
clear: () ->
@params.clear()
@redirect()
toObject: -> @params.toObject()
@accessor
get: (k) -> @params.get(k)
set: (k,v) ->
oldValue = @params.get(k)
result = @params.set(k,v)
@redirect() if oldValue isnt v
result
unset: (k) ->
hadKey = @params.hasKey(k)
result = @params.unset(k)
@redirect() if hadKey
result
class Batman.ParamsPusher extends Batman.ParamsReplacer
redirect: -> @navigator.push(@toObject()) |
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
resource = "#{options.parentResource}/:#{helpers.singularize(options.parentResource)}Id/#{resource}" if options.parentResource
@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}"
resources: (childResource, options={}, callback) =>
(callback = options; options = {}) if typeof options is 'function'
options.parentResource = resource
@resources childResource, options, callback
callback.call ops
redirect: $redirect |
Controllers | class Batman.Controller extends Batman.Object
@singleton 'sharedController'
@accessor 'controllerName', -> @_controllerName ||= helpers.underscore($functionName(@constructor).replace('Controller', ''))
@beforeFilter: (nameOrFunction) ->
Batman.initializeObject @
filters = @_batman.beforeFilters ||= []
filters.push(nameOrFunction) if filters.indexOf(nameOrFunction) is -1
@afterFilter: (nameOrFunction) ->
Batman.initializeObject @
filters = @_batman.afterFilters ||= []
filters.push(nameOrFunction) if filters.indexOf(nameOrFunction) is -1 |
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.navigator?.redirect
Batman.navigator?.redirect = @redirect
@_inAction = yes
@_actedDuringAction = no
@set 'action', action
@set 'params', params
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
delete @_inAction
Batman.navigator?.redirect = oldRedirect
redirectTo = @_afterFilterRedirect
delete @_afterFilterRedirect
$redirect(redirectTo) if redirectTo
redirect: (url) =>
if @_actedDuringAction && @_inAction
developer.warn "Warning! Trying to redirect but an action has already be taken during #{@get('controllerName')}.#{@get('action')}}"
if @_inAction
@_actedDuringAction = yes
@_afterFilterRedirect = url
else
if $typeOf(url) is 'Object'
url.controller = @ if not url.controller
$redirect url
render: (options = {}) ->
if @_actedDuringAction && @_inAction
developer.warn "Warning! Trying to render but an action has already be taken during #{@get('controllerName')}.#{@get('action')}"
@_actedDuringAction = yes
return if options is false
if not options.view
options.context ||= @
options.source ||= helpers.underscore(@get('controllerName') + '/' + @get('action'))
options.view = new (Batman.currentApp?[helpers.camelize("#{@get('controllerName')}_#{@get('action')}_view")] || Batman.View)(options)
if view = options.view
Batman.currentApp?.prevent 'ready'
view.on 'ready', =>
node = view.get('node')
yieldTo = options.into || 'main'
if view.hasContainer
if yieldingNode = Batman.DOM._yields[yieldTo]
$setInnerHTML yieldingNode, ''
while node.childNodes.length > 0
$appendChild(yieldingNode, node.childNodes[0])
else
Batman.DOM.replace yieldTo, node
Batman.currentApp?.allowAndFire 'ready'
view.ready?(@params)
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 |
Associations API | for k in ['belongsTo', 'hasOne', 'hasMany']
do (k) =>
@[k] = (label, scope) ->
@_batman.check(@)
collection = @_batman.associations ||= new Batman.AssociationCollection(@)
collection.add new Batman["#{helpers.capitalize(k)}Association"](@, label, scope)
associationProxy: (association) ->
Batman.initializeObject(@)
proxies = @_batman.associationProxies ||= new Batman.SimpleHash
proxies.get(association.label) or proxies.set(association.label, new association.proxyClass(association, @)) |
Record API | |
Add a universally accessible accessor for retrieving the primrary key, regardless of which key its stored under. | @accessor 'id',
get: ->
pk = @constructor.primaryKey
if pk == 'id'
@id
else
@get(pk)
set: (k, v) -> |
naively coerce string ids into integers | if typeof v is "string" and v.match(/[^0-9]/) is null
v = parseInt(v, 10)
pk = @constructor.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) ->
attribute = (@_batman.attributes ||= {})[k]
if typeof attribute isnt 'undefined'
attribute
else
@[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(val, key, obj, @)
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], key, data, obj, @) unless typeof data[key] is 'undefined'
developer.do =>
if (!decoders) || decoders.length <= 1
developer.warn "Warning: Model #{$functionName(@constructor)} has suspiciously few decoders!" |
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
associations = @constructor._batman.associations?.getAll() |
Save belongsTo models immediately since we don't need this model's id | associations?.get('belongsTo')?.forEach (association, label) => association.apply(@)
@_doStorageOperation (if creating then 'create' else 'update'), {}, (err, record) =>
unless err
if creating
do @created
do @saved
@dirtyKeys.clear()
associations?.get('hasOne')?.forEach (association) -> association.apply(err, record)
associations?.get('hasMany')?.forEach (association) -> association.apply(err, record)
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.AssociationCollection
@availableAssociations: ['belongsTo', 'hasOne', 'hasMany']
constructor: (@model) -> |
Contains (association, label) pairs mapped by association type
ie. @storage = { | @storage = new Batman.SimpleHash
add: (association) ->
unless associationTypeHash = @storage.get(association.constructor)
associationTypeHash = new Batman.SimpleHash
@storage.set association.associationType, associationTypeHash
associationTypeHash.set association, association.label
get: (key) -> @storage.get(key)
getAll: -> |
Traverse the class heirarchy to get all the AssociationCollection objects | @model._batman.check(@model)
ancestorCollections = @model._batman.ancestors((ancestor) -> ancestor._batman.get('associations'))
newStorage = new Batman.SimpleHash |
Flatten the deep hashes to merge the ancestors into the final, inherited storage for this model. | for key in Batman.AssociationCollection.availableAssociations
ancestorValuesAtKey = for ancestorCollection in ancestorCollections when ancestorCollection? and val = ancestorCollection.get(key)
val
newStorage.set key, (@storage.get(key) || new Batman.SimpleHash).merge(ancestorValuesAtKey...)
@storage = newStorage |
Gives {hasMany: Hash{ | @getAll = -> @storage
@storage
associationForLabel: (searchLabel) ->
ret = undefined
@getAll().forEach (type, associations) ->
return if ret
associations.forEach (association, label) ->
return if ret
ret = association if label == searchLabel
ret
class Batman.Association
associationType: ''
defaultOptions:
saveInline: true
autoload: true
constructor: (@model, @label, options = {}) ->
defaultOptions =
namespace: Batman.currentApp
name: helpers.camelize(helpers.singularize(@label))
@options = $mixin defaultOptions, @defaultOptions, options |
Setup encoders and accessors for this association. The accessor needs reference to this association object, so curry the association info into the getAccessor, which has the model applied as the context | model.encode label, @encoder()
self = @
getAccessor = -> return self.getAccessor.call(@, self, model, label)
model.accessor label,
get: getAccessor
set: model.defaultAccessor.set
unset: model.defaultAccessor.unset
setIndex: ->
@index ||= new Batman.AssociationSetIndex(@)
@index
getAccessor: (self, model, label) -> |
Check whether the relation has already been set on this model | if recordInAttributes = self.getFromAttributes(@)
return recordInAttributes |
Make sure the related model has been loaded | if self.getRelatedModel()
proxy = @associationProxy(self)
if not proxy.get('loaded') and self.options.autoload
proxy.load()
proxy
getRelatedModel: ->
scope = @options.namespace or Batman.currentApp
modelName = @options.name
scope?[modelName]
getFromAttributes: (record) ->
record.constructor.defaultAccessor.get.call(record, @label)
encoder: -> developer.error "You must override encoder in Batman.Association subclasses."
inverse: ->
if @options.inverseOf
@getRelatedModel()._batman.associations.associationForLabel(@options.inverseOf)
class Batman.AssociationProxy extends Batman.Object
constructor: (@association, @model) ->
loaded: false
toJSON: ->
if @loaded
@get('target').toJSON()
load: (callback) ->
@fetch (err, relation) =>
@set('target', relation)
callback?(undefined, relation)
@get('target')
@accessor 'loaded'
get: -> @loaded
set: (_, v) -> @loaded = v
@accessor 'target',
get: ->
if id = @model.get(@association.localKey)
@association.getRelatedModel().get('loaded').indexedByUnique('id').get(id)
set: (v) -> v # This just needs to bust the cache
@accessor
get: (k) -> @get('target')?.get(k)
set: (k, v) -> @get('target')?.set(k, v)
class Batman.BelongsToProxy extends Batman.AssociationProxy
fetch: (callback) ->
if relatedID = @model.get(@association.localKey)
loadedRecords = @association.setIndex().get(relatedID)
unless loadedRecords.isEmpty()
@set 'loaded', true
callback undefined, loadedRecords.toArray()[0]
else
@association.getRelatedModel().find relatedID, (error, loadedRecord) =>
throw error if error
@set('loaded', true) if loadedRecord
callback undefined, loadedRecord
class Batman.HasOneProxy extends Batman.AssociationProxy
fetch: (callback) ->
if id = @model.get(@association.localKey) |
Check whether the relatedModel has already loaded the instance we want | relatedRecords = @association.setIndex().get(id)
unless relatedRecords.isEmpty()
@set('loaded', true)
callback undefined, relatedRecords.toArray()[0]
else
loadOptions = {}
loadOptions[@association.foreignKey] = id
@association.getRelatedModel().load loadOptions, (error, loadedRecords) =>
throw error if error
if !loadedRecords or loadedRecords.length <= 0
callback new Error("Couldn't find related record!"), undefined
else
@set('loaded', true)
callback undefined, loadedRecords[0]
class Batman.AssociationSet extends Batman.Set
constructor: (@key, @association) -> super()
loaded: false
load: (callback) ->
loadOptions = {}
loadOptions[@association.foreignKey] = @key
@association.getRelatedModel().load loadOptions, (err, records) =>
@loaded = true unless err
callback(err, @)
class Batman.AssociationSetIndex extends Batman.SetIndex
constructor: (@association) ->
super @association.getRelatedModel().get('loaded'),
@association.foreignKey
_resultSetForKey: (key) ->
@_storage.getOrSet key, =>
new Batman.AssociationSet(key, @association)
class Batman.BelongsToAssociation extends Batman.Association
associationType: 'belongsTo'
proxyClass: Batman.BelongsToProxy
defaultOptions:
saveInline: false
autoload: true
constructor: ->
super
@localKey = "#{@label}_id"
@foreignKey = 'id'
@model.encode @localKey
encoder: ->
association = @
return {
encode: (val) ->
return unless association.options.saveInline
val.toJSON()
decode: (data, _, _, _, childRecord) ->
relatedModel = association.getRelatedModel()
record = new relatedModel()
record.fromJSON(data)
record = relatedModel._mapIdentity(record)
if association.options.inverseOf
if inverse = association.inverse()
if inverse instanceof Batman.HasManyAssociation |
Rely on the parent's set index to get this out. | childRecord.set(association.localKey, record.get(association.foreignKey))
else
record.set(inverse.label, childRecord)
childRecord.set(association.label, record)
record
}
apply: (base) ->
if model = base.get(@label)
base.set @localKey, model.get(@foreignKey)
class Batman.HasOneAssociation extends Batman.Association
associationType: 'hasOne'
proxyClass: Batman.HasOneProxy
constructor: ->
super
@localKey = "id"
@foreignKey = "#{helpers.underscore($functionName(@model))}_id"
apply: (baseSaveError, base) ->
if relation = base.constructor.defaultAccessor.get.call(base, @label)
relation.set @foreignKey, base.get(@localKey)
encoder: ->
association = @
return {
encode: (val, key, object, record) ->
return unless association.options.saveInline
if json = val.toJSON()
json[association.foreignKey] = record.get(association.localKey)
json
decode: (data, _, _, _, parentRecord) ->
relatedModel = association.getRelatedModel()
record = new (relatedModel)()
record.fromJSON(data)
if association.options.inverseOf
record.set association.options.inverseOf, parentRecord
record = relatedModel._mapIdentity(record)
record
}
class Batman.HasManyAssociation extends Batman.Association
associationType: 'hasMany'
constructor: ->
super
@localKey = "id"
@foreignKey = "#{helpers.underscore($functionName(@model))}_id"
getAccessor: (self, model, label) ->
return if @amSetting
return unless self.getRelatedModel() |
Check whether the relation has already been set on this model | if recordInAttributes = self.getFromAttributes(@)
return recordInAttributes
if id = @get(self.localKey)
relatedRecords = self.setIndex().get(id)
@amSetting = true
@set label, relatedRecords
@amSetting = false
if self.options.autoload and not relatedRecords.loaded
relatedRecords.load (error, records) -> throw error if error
return relatedRecords
apply: (baseSaveError, base) ->
if relations = base.constructor.defaultAccessor.get.call(base, @label)
relations.forEach (model) =>
model.set @foreignKey, base.get(@localKey)
encoder: ->
association = @
return {
encode: (relationSet, _, _, record) ->
return if association._beingEncoded
association._beingEncoded = true
return unless association.options.saveInline
if relationSet?
jsonArray = []
relationSet.forEach (relation) ->
relationJSON = relation.toJSON()
relationJSON[association.foreignKey] = record.get(association.localKey)
jsonArray.push relationJSON
delete association._beingEncoded
jsonArray
decode: (data, _, _, _, parentRecord) ->
relations = new Batman.Set
if relatedModel = association.getRelatedModel()
for jsonObject in data
record = new relatedModel
record.fromJSON jsonObject
if association.options.inverseOf
record.set association.options.inverseOf, parentRecord
record = relatedModel._mapIdentity(record)
relations.add record
else
developer.error "Can't decode model #{association.options.name} because it hasn't been loaded yet!"
relations
}
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)
isStorageAdapter: true
modelKey: (record) ->
model = record?.constructor || @model
model.get('storageKey') || helpers.pluralize(helpers.underscore($functionName(model)))
@::_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
return
storageRegExp: (record) -> new RegExp("^#{@modelKey(record)}(\\d+)$")
idForRecord: (record) ->
re = @storageRegExp(record)
nextId = 1
@_forAllRecords (k, v) ->
if matches = re.exec(k)
nextId = Math.max(nextId, parseInt(matches[1], 10) + 1)
nextId
@::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(record) + 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', @idForRecord(record))
if id?
key = @modelKey(record) + 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(record) + 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: (proto, options, callback) ->
records = []
[err, options] = @_filterData('before', 'readAll', undefined, options)
if !err
re = @storageRegExp(proto)
@_forAllRecords (storageKey, data) ->
if keyMatches = 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(record) + 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
@defaultOptions = $mixin {}, @defaultOptions
recordJsonNamespace: (record) ->
helpers.singularize(@modelKey(record))
collectionJsonNamespace: (proto) ->
helpers.pluralize(@modelKey(proto))
@::before 'create', 'update', $passError ([record, options]) ->
json = record.toJSON()
record = if namespace = @recordJsonNamespace(record)
x = {}
x[namespace] = json
x
else
json
record = JSON.stringify(record) unless @serializeAsForm
[record, options]
@::after 'create', 'read', 'update', 'destroy', ([error, record, data, options]) ->
if !error
if typeof data is 'string'
try
data = JSON.parse(data)
catch e
error = e
error.data = data
[error, record, data, options]
@::after 'readAll', ([error, data, proto, options]) ->
if !error
if typeof data is 'string'
try
data = JSON.parse(data)
catch e
error = e
error.data = data
[error, data, proto, options]
@::after 'create', 'read', 'update', $passError ([record, data, options]) ->
namespace = @recordJsonNamespace(record)
data = data[namespace] if namespace && data[namespace]?
[record, data, options]
@::after 'create', 'read', 'update', $passError ([record, data, options]) ->
record.fromJSON(data)
[record, data, options]
optionsForRecord: (record, recordOptions, callback) ->
if record.url
url = if typeof record.url is 'function' then record.url(recordOptions) else record.url
else
url = if record.constructor.url
if typeof record.constructor.url is 'function'
record.constructor.url(recordOptions)
else
record.constructor.url
else
"/#{@modelKey(record)}"
if recordOptions.idRequired
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: (model, recordsOptions, callback) ->
if model.url
url = if typeof model.url is 'function' then model.url(recordsOptions) else model.url
else
url = "/#{@modelKey(model::)}"
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, {idRequired: 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, {idRequired: 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, {idRequired: 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: (proto, recordsOptions, callback) ->
@optionsForCollection proto.constructor, 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, proto, recordsOptions)...)
error: (error) => callback(@_filterData('after', 'readAll', error, error.request.get('response'), proto, recordsOptions)...)
@::after 'readAll', $passError ([data, proto, options]) ->
namespace = @collectionJsonNamespace(proto)
recordData = if namespace && data[namespace]? then data[namespace] else data
[recordData, data, proto, options]
@::after 'readAll', $passError ([recordData, serverData, proto, options]) ->
[@getRecordFromData(attributes) for attributes in recordData, serverData, proto, options]
destroy: (record, recordOptions, callback) ->
@optionsForRecord record, {idRequired: 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 | class Batman.ViewSourceCache extends Batman.Object
constructor: ->
super
@sources = {}
@requests = {}
propertyClass: Batman.Property
@accessor
get: (path) ->
path = Batman.Navigator.normalizePath(path)
return @sources[path] if @sources[path]?
unless @requests[path]?
@requests = new Batman.Request
url: path + '.html'
type: 'html'
success: (response) => @set(path, response)
error: (response) -> throw new Error("Could not load view from #{path}")
return undefined
set: (k,v) -> @sources[k] = v
final: true
prefetch: (path) ->
@get(path)
true |
A | class Batman.View extends Batman.Object
constructor: ->
super |
Start the rendering by asking for the node | if node = @get('node')
@render node
else
@observe 'node', (node) => @render(node)
@sourceCache: new Batman.ViewSourceCache() |
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 |
Fires once a node is parsed. | @::event('ready').oneShot = true |
Where to look for views on the server | prefix: 'views'
@accessor 'html',
get: ->
return @html if @html && @html.length > 0
source = @get 'source'
return if not source
path = Batman.Navigator.normalizePath(@prefix, source)
@html = @constructor.sourceCache.get(path)
set: (_, html) -> @html = html
@accessor 'node'
get: ->
unless @node
html = @get('html')
return unless html && html.length > 0
@hasContainer = true
@node = document.createElement 'div'
$setInnerHTML(@node, html)
return @node
set: (_, node) -> @node = node
render: (node) ->
@event('ready').resetOneShot()
@_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, null, @context)
@_renderer.on 'rendered', => @fire('ready', node) |
DOM Helpers | |
| class Batman.Renderer extends Batman.Object
deferEvery: 50
constructor: (@node, callback, context) ->
super()
@on('parsed', callback) if callback?
@context = if context instanceof Batman.RenderContext then context else Batman.RenderContext.start(context)
@immediate = $setImmediate @start
start: =>
@startTime = new Date
@parseNode @node
resume: =>
@startTime = new Date
@parseNode @resumeNode
finish: ->
@startTime = null
@prevent 'stopped'
@fire 'parsed'
@fire 'rendered'
stop: ->
$clearImmediate @immediate
@fire 'stopped'
forgetAll: ->
for k in ['parsed', 'rendered', 'stopped']
@::event(k).oneShot = true
bindingRegexp = /^data\-(.*)/ |
Bindings processed first appear in this array last | bindingSortOrder = ['bind', 'context', 'formfor', 'foreach', 'renderif']
sortBindings = (a, b) ->
aindex = bindingSortOrder.indexOf(a[0])
bindex = bindingSortOrder.indexOf(b[0])
if aindex > bindex
-1
else if bindex > aindex
1
else
0
parseNode: (node) ->
if @deferEvery && (new Date - @startTime) > @deferEvery
@resumeNode = node
@timeout = $setImmediate @resume
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
oldContext = @context
@context = result
$onParseExit(node, => @context = oldContext)
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
sibling = node.nextSibling # Grab the reference before onParseExit may remove the node
$onParseExit(node).forEach (callback) -> callback()
$forgetParseExit(node)
return if @node == node
return sibling if sibling
nextParent = node
while nextParent = nextParent.parentNode
$onParseExit(nextParent).forEach (callback) -> callback()
$forgetParseExit(nextParent)
return if @node == nextParent
parentSibling = nextParent.nextSibling
return parentSibling if parentSibling
return |
The RenderContext class manages the stack of contexts accessible to a view during rendering. | class Batman.RenderContext
@start: (context) ->
@windowWrapper ||= window: Batman.container
node = new @(@windowWrapper)
node = node.descend Batman.currentApp if Batman.currentApp
node = node.descend(context) if 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
@windowWrapper ||= window: Batman.container
[$get(@windowWrapper, key), @windowWrapper] |
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) |
| 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 Batman.DOM.AbstractBinding(undefined, @keyPath, @renderContext)
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) ->
bindingClass = false
switch node.nodeName.toLowerCase()
when 'input'
switch node.getAttribute('type')
when 'checkbox'
Batman.DOM.attrReaders.bind(node, 'checked', key, context, renderer, only)
return true
when 'radio'
bindingClass = Batman.DOM.RadioBinding
when 'file'
bindingClass = Batman.DOM.FileBinding
when 'select'
bindingClass = Batman.DOM.SelectBinding
bindingClass ||= Batman.DOM.Binding
new bindingClass(arguments...)
true
context: (node, key, context, renderer) -> return context.descendWithKey(key)
mixin: (node, key, context, renderer) ->
new Batman.DOM.MixinBinding(node, key, context.descend(Batman.mixins), renderer)
true
showif: (node, key, context, parentRenderer, invert) ->
new Batman.DOM.ShowHideBinding(node, key, context, parentRenderer, false, invert)
true
hideif: -> Batman.DOM.readers.showif(arguments..., yes)
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, _] = context.findKey key if not isHash
model = model.get('target') if model instanceof Batman.AssociationProxy
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.Navigator.defaultClass()::linkTo(url)
Batman.DOM.events.click node, -> $redirect url
true
partial: (node, path, context, renderer) ->
Batman.DOM.partial node, path, context, renderer
true
defineview: (node, name, context, renderer) ->
$onParseExit(node, -> $removeNode(node))
Batman.View.sourceCache.set(Batman.Navigator.normalizePath(Batman.View::prefix, name), node.innerHTML)
false
renderif: (node, key, context, renderer) ->
new Batman.DOM.DeferredRenderingBinding(node, key, context, renderer)
false
yield: (node, key) ->
$setImmediate -> Batman.DOM.yield key, node
true
contentfor: (node, key) ->
$setImmediate -> Batman.DOM.contentFor key, node
true
replace: (node, key) ->
$setImmediate -> Batman.DOM.replace key, node
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) ->
bindingClass = switch attr
when 'checked', 'disabled', 'selected'
Batman.DOM.CheckedBinding
when 'value', 'href', 'src', 'size'
Batman.DOM.NodeAttributeBinding
when 'class'
Batman.DOM.ClassBinding
when 'style'
Batman.DOM.StyleBinding
else
Batman.DOM.AttributeBinding
new bindingClass(arguments...)
true
context: (node, contextName, key, context) -> return context.descendWithKey(key, contextName)
event: (node, eventName, key, context) ->
new Batman.DOM.EventBinding(arguments...)
true
addclass: (node, className, key, context, parentRenderer, invert) ->
new Batman.DOM.AddClassBinding(node, className, key, context, parentRenderer, false, invert)
true
removeclass: (node, className, key, context, parentRenderer) -> Batman.DOM.attrReaders.addclass node, className, key, context, parentRenderer, yes
foreach: (node, iteratorName, key, context, parentRenderer) ->
new Batman.DOM.IteratorBinding(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)
} |
| 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
other: (node, eventName, callback) -> $addEventListener node, eventName, (args...) -> callback node, args...
} |
| 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, '', true
for content in contents when !Batman._data(content, 'yielded')
content = content.cloneNode(true) if $isChildOf(node, content)
$appendChild node, content, true
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
partial: (container, path, context, renderer) ->
renderer.prevent 'rendered'
view = new Batman.View
source: path
context: context
view.on 'ready', ->
$setInnerHTML container, '' |
Render the partial content into the data-partial node Text nodes move when they are appended, so copy childNodes | children = (node for node in view.get('node').childNodes)
$appendChild(container, child) for child in children
renderer.allowAndFire 'rendered' |
Adds a binding or binding-like object to the | trackBinding: $trackBinding = (binding, node) ->
if bindings = Batman._data node, 'bindings'
bindings.add binding
else
Batman._data node, 'bindings', new Batman.SimpleSet(binding) |
Removes listeners and bindings tied to | unbindNode: $unbindNode = (node) -> |
break down all bindings | if bindings = Batman._data node, 'bindings'
bindings.forEach (binding) -> binding.destroy() |
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 # external data (Batman.data)
Batman.removeData node, undefined, true # internal data (Batman._data) |
Unbinds the tree rooted at | unbindTree: $unbindTree = (node, unbindRoot = true) ->
$unbindNode node if unbindRoot
$unbindTree(child) for child in node.childNodes |
Memory-safe setting of a node's innerHTML property | setInnerHTML: $setInnerHTML = (node, html, args...) ->
hide.apply(child, args) for child in node.childNodes when hide = Batman.data(child, 'hide')
$unbindTree node, false
node?.innerHTML = html
setStyleProperty: $setStyleProperty = (node, property, value, importance) ->
if node.style.setAttribute
node.style.setAttribute(property, value, importance)
else
node.style.setProperty(property, value, importance) |
Memory-safe removal of a node from the DOM | removeNode: $removeNode = (node) ->
node.parentNode?.removeChild node
Batman.DOM.didRemoveNode(node)
appendChild: $appendChild = (parent, child, args...) ->
Batman.data(child, 'show')?.apply(child, args)
parent.appendChild(child)
insertBefore: $insertBefore = (parentNode, newNode, referenceNode = null) ->
if !referenceNode or parentNode.childNodes.length <= 0
$appendChild parentNode, newNode
else
parentNode.insertBefore newNode, referenceNode
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
didRemoveNode: (node) -> $unbindTree node
onParseExit: $onParseExit = (node, callback) ->
set = Batman._data(node, 'onParseExit') || Batman._data(node, 'onParseExit', new Batman.SimpleSet)
set.add callback if callback?
set
forgetParseExit: $forgetParseExit = (node, callback) -> Batman.removeData(node, 'onParseExit', true)
} |
Bindings are shortlived objects which manage the observation of any keypaths a | class Batman.DOM.AbstractBinding 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.
[\?\!]? # Allow ? and ! at the end of a keypath to support Ruby's methods
)
\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'
get: ->
unfilteredValue = @get('unfilteredValue')
self = @
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 = self.filterArguments[i].map (argument) ->
if argument._keypath
self.renderContext.findKey(argument._keypath)[0]
else
argument |
Apply the filter. | args.unshift value
args = args.map deProxy
fn.apply(self.renderContext, args)
, unfilteredValue)
developer.currentFilterStack = null
result
else
deProxy(unfilteredValue) |
We ignore any filters for setting, because they often aren't reversible. | set: (_, newValue) -> @set('unfilteredValue', newValue) |
The | @accessor 'unfilteredValue'
get: -> |
If we're working with an | if k = @get('key')
@get("keyContext.#{k}")
else
@get('value')
set: (_, value) ->
if k = @get('key')
keyContext = @get('keyContext') |
Supress sets on the window | if keyContext != Batman.container
@set("keyContext.#{k}", value)
else
@set('value', value) |
The | @accessor 'keyContext', -> @renderContext.findKey(@key)[1]
bindImmediately: true
constructor: (@node, @keyPath, @renderContext, @renderer, @only = false) ->
Batman.DOM.trackBinding(@, @node) if @node? |
Pull out the | @parseFilter() |
Observe the node and the data. | @bind() if @bindImmediately
bind: ->
shouldSet = yes |
Attach the observers. | if @node? && @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)
destroy: ->
@forget()
@_batman.properties?.forEach (key, property) -> property.die()
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}\"!" |
Turn a piece of a | parseSegment: (segment) ->
JSON.parse( "[" + segment.replace(keypath_rx, "$1{\"_keypath\": \"$2\"}") + "]" )
class Batman.DOM.AbstractAttributeBinding extends Batman.DOM.AbstractBinding
constructor: (node, @attributeName, args...) -> super(node, args...)
class Batman.DOM.AbstractCollectionBinding extends Batman.DOM.AbstractAttributeBinding
bindCollection: (newCollection) ->
unless newCollection == @collection
@unbindCollection()
@collection = newCollection
if @collection
if @collection.isObservable && @collection.toArray
@collection.observe 'toArray', @handleArrayChanged
else if @collection.isEventEmitter
@collection.on 'itemsWereAdded', @handleItemsWereAdded
@collection.on 'itemsWereRemoved', @handleItemsWereRemoved
else
return false
return true
return false
unbindCollection: ->
if @collection
if @collection.isObservable && @collection.toArray
@collection.forget('toArray', @handleArrayChanged)
else if @collection.isEventEmitter
@collection.event('itemsWereAdded').removeHandler(@handleItemsWereAdded)
@collection.event('itemsWereRemoved').removeHandler(@handleItemsWereRemoved)
handleItemsWereAdded: ->
handleItemsWereRemoved: ->
handleArrayChanged: ->
destroy: ->
@unbindCollection()
super
class Batman.DOM.Binding extends Batman.DOM.AbstractBinding
nodeChange: (node, context) ->
if @key && @filterFunctions.length == 0
@set 'filteredValue', @node.value
dataChange: (value, node) ->
Batman.DOM.valueForNode @node, value
class Batman.DOM.AttributeBinding extends Batman.DOM.AbstractAttributeBinding
dataChange: (value) -> @node.setAttribute(@attributeName, value)
nodeChange: (node) -> @set 'filteredValue', Batman.DOM.attrReaders._parseAttribute(node.getAttribute(@attributeName))
class Batman.DOM.NodeAttributeBinding extends Batman.DOM.AbstractAttributeBinding
dataChange: (value) -> @node[@attributeName] = value
nodeChange: (node) -> @set 'filteredValue', Batman.DOM.attrReaders._parseAttribute(node[@attributeName])
class Batman.DOM.ShowHideBinding extends Batman.DOM.AbstractBinding
constructor: (node, className, key, context, parentRenderer, @invert = false) ->
@originalDisplay = node.style.display || ''
super
dataChange: (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
$setStyleProperty(@node, 'display', 'none', 'important')
class Batman.DOM.CheckedBinding extends Batman.DOM.NodeAttributeBinding
dataChange: (value) ->
@node[@attributeName] = !!value |
Update the parent's binding if necessary | Batman._data(@node.parentNode, 'updateBinding')?()
constructor: ->
super |
Attach this binding to the node under the attribute name so that parent
bindings can query this binding and modify its state. This is useful
for | Batman._data @node, @attributeName, @
class Batman.DOM.ClassBinding extends Batman.DOM.AbstractCollectionBinding
dataChange: (value) ->
if value?
@unbindCollection()
if typeof value is 'string'
@node.className = value
else
@bindCollection(value)
@updateFromCollection()
updateFromCollection: ->
if @collection
array = if @collection.map
@collection.map((x) -> x)
else
(k for own k,v of @collection)
array = array.toArray() if array.toArray?
@node.className = array.join ' '
handleArrayChanged: => @updateFromCollection()
handleItemsWereRemoved: => @updateFromCollection()
handleItemsWereAdded: => @updateFromCollection()
class Batman.DOM.DeferredRenderingBinding extends Batman.DOM.AbstractBinding
rendered: false
constructor: ->
super
@node.removeAttribute "data-renderif"
nodeChange: ->
dataChange: (value) ->
if value && !@rendered
@render()
render: ->
new Batman.Renderer(@node, null, @renderContext)
@rendered = true
class Batman.DOM.AddClassBinding extends Batman.DOM.AbstractAttributeBinding
constructor: (node, className, keyPath, renderContext, renderer, only, @invert = false) ->
@className = className.replace(/\|/g, ' ')
super
delete @attributeName
dataChange: (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
class Batman.DOM.EventBinding extends Batman.DOM.AbstractAttributeBinding
bindImmediately: false
constructor: ->
super
confirmText = @node.getAttribute('data-confirm')
callback = =>
return if confirmText and not confirm(confirmText)
@get('filteredValue')?.apply @get('callbackContext'), arguments
if attacher = Batman.DOM.events[@attributeName]
attacher @node, callback
else
Batman.DOM.events.other @node, @attributeName, callback
@accessor 'callbackContext', ->
contextKeySegments = @key.split('.')
contextKeySegments.pop()
context = if contextKeySegments.length > 0
@get('keyContext').get(contextKeySegments.join('.'))
else
@get('keyContext')
class Batman.DOM.RadioBinding extends Batman.DOM.AbstractBinding
dataChange: (value) -> |
don't overwrite | if (boundValue = @get('filteredValue'))?
@node.checked = boundValue == @node.value
else if @node.checked
@set 'filteredValue', @node.value
nodeChange: (node) ->
@set('filteredValue', Batman.DOM.attrReaders._parseAttribute(node.value))
class Batman.DOM.FileBinding extends Batman.DOM.AbstractBinding
nodeChange: (node, subContext) ->
segments = @key.split('.')
if segments.length > 1
keyContext = subContext.get(segments.slice(0, -1).join('.'))
else
keyContext = subContext
if keyContext instanceof Batman.RenderContext.ContextProxy
actualObject = keyContext.get('proxiedObject')
else
actualObject = keyContext
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')
@set 'filteredValue', Array::slice.call(node.files)
else
@set 'filteredValue', node.value
class Batman.DOM.MixinBinding extends Batman.DOM.AbstractBinding
dataChange: (value) -> $mixin @node, value if value?
class Batman.DOM.SelectBinding extends Batman.DOM.AbstractBinding
bindImmediately: false
constructor: ->
super |
wait for the select to render before binding to it | @renderer.on 'rendered', =>
if @node?
Batman._data @node, 'updateBinding', @updateSelectBinding
@bind()
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, update the value. | else
Batman.DOM.valueForNode(@node, newValue) |
Finally, update the options' | @updateOptionBindings()
nodeChange: =>
@updateSelectBinding()
@updateOptionBindings()
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
@set 'unfilteredValue', selections
true
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 selectedBinding = Batman._data(child, 'selected')
selectedBinding.nodeChange(selectedBinding.node)
true
class Batman.DOM.StyleBinding extends Batman.DOM.AbstractCollectionBinding
class @SingleStyleBinding extends Batman.DOM.AbstractAttributeBinding
constructor: (args..., @parent) ->
super(args...)
dataChange: (value) -> @parent.setStyle(@attributeName, value)
constructor: ->
@oldStyles = {}
super
dataChange: (value) ->
unless value
@reapplyOldStyles()
return
@unbindCollection()
if typeof value is 'string'
@reapplyOldStyles()
for style in value.split(';') |
handle a case when css value contains colons itself (absolute URI) split and rejoin because IE7/8 don't splice values of capturing regexes into split's return array | [cssName, colonSplitCSSValues...] = style.split(":")
@setStyle cssName, colonSplitCSSValues.join(":")
return
if value instanceof Batman.Hash
if @bindCollection(value)
value.forEach (key, value) => @setStyle key, value
else if value instanceof 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] = @renderContext.findKey(keyValue)
if keypathValue
@bindSingleAttribute key, keyValue
@setStyle key, keypathValue
else
@setStyle key, keyValue
handleItemsWereAdded: (newKey) => @setStyle newKey, @collection.get(newKey); return
handleItemsWereRemoved: (oldKey) => @setStyle oldKey, ''; return
bindSingleAttribute: (attr, keyPath) -> new @constructor.SingleStyleBinding(@node, attr, keyPath, @renderContext, @renderer, @only, @)
setStyle: (key, value) =>
return unless key
key = helpers.camelize(key.trim(), true)
@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.IteratorBinding extends Batman.DOM.AbstractCollectionBinding
deferEvery: 50
currentActionNumber: 0
queuedActionNumber: 0
bindImmediately: false
constructor: (sourceNode, @iteratorName, @key, @context, @parentRenderer) ->
@nodeMap = new Batman.SimpleHash
@actionMap = new Batman.SimpleHash
@rendererMap = new Batman.SimpleHash
@actions = []
@prototypeNode = sourceNode.cloneNode(true)
@prototypeNode.removeAttribute "data-foreach-#{@iteratorName}"
@parentNode = sourceNode.parentNode
previousSiblingNode = sourceNode.nextSibling
@siblingNode = document.createComment "end #{@iteratorName}"
@siblingNode[Batman.expando] = sourceNode[Batman.expando]
delete sourceNode[Batman.expando] if Batman.canDeleteExpando
$insertBefore @parentNode, @siblingNode, previousSiblingNode |
Remove the original node once the parent has moved past it. | @parentRenderer.on 'parsed', => |
Move any Batman._data from the sourceNode to the sibling; we need to retain the bindings, and we want to dispose of the node. | $removeNode sourceNode |
Attach observers. | @bind() |
Don't let the parent emit its rendered event until all the children have.
This | @parentRenderer.prevent 'rendered' |
Tie this binding to a node using the default behaviour in the AbstractBinding | super(@siblingNode, @iteratorName, @key, @context, @parentRenderer)
@fragment = document.createDocumentFragment()
destroy: ->
super
@destroyed = true
unbindCollection: ->
if @collection
@nodeMap.forEach (item) => @cancelExistingItem(item)
super
dataChange: (newCollection) ->
if @collection != newCollection
@removeAll()
@bindCollection(newCollection) # Unbinds the old collection as well.
if @collection
if @collection.toArray
@handleArrayChanged()
else if @collection.forEach
@collection.forEach (item) => @addOrInsertItem(item)
else
@addOrInsertItem(key) for own key, value of @collection
else
developer.warn "Warning! data-foreach-#{@iteratorName} called with an undefined binding. Key was: #{@key}."
@processActionQueue()
handleItemsWereAdded: (items...) => @addOrInsertItem(item, {fragment: false}) for item in items; return
handleItemsWereRemoved: (items...) => @removeItem(item) for item in items; return
handleArrayChanged: =>
newItemsInOrder = @collection.toArray()
nodesToRemove = (new Batman.SimpleHash).merge(@nodeMap)
for item in newItemsInOrder
@addOrInsertItem(item, {fragment: false})
nodesToRemove.unset(item)
nodesToRemove.forEach (item, node) => @removeItem(item)
addOrInsertItem: (item, options = {}) ->
existingNode = @nodeMap.get(item)
if existingNode
@insertItem(item, existingNode, $mixin(options, {actionNumber: @queuedActionNumber++}))
else
@addItem(item, options)
addItem: (item, options = {fragment: true}) ->
@parentRenderer.prevent 'rendered' |
Remove any renderers in progress or actions lined up for an item, since we now know this item belongs at the end of the queue. | @cancelExistingItemActions(item) if @actionMap.get(item)?
self = @
options.actionNumber = @queuedActionNumber++ |
Render out the child in the custom context, and insert it once the render has completed the parse. | childRenderer = new Batman.Renderer @_nodeForItem(item), (->
self.rendererMap.unset(item)
self.insertItem(item, @node, options)
), @renderContext.descend(item, @iteratorName)
@rendererMap.set(item, childRenderer)
finish = =>
return if @destroyed
@parentRenderer.allowAndFire 'rendered'
childRenderer.on 'rendered', finish
childRenderer.on 'stopped', =>
return if @destroyed
@actions[options.actionNumber] = false
finish()
@processActionQueue()
item
removeItem: (item) ->
return if @destroyed || !item?
oldNode = @nodeMap.unset(item)
@cancelExistingItem(item)
if oldNode
if hideFunction = Batman.data oldNode, 'hide'
hideFunction.call(oldNode)
else
$removeNode(oldNode)
removeAll: -> @nodeMap.forEach (item) => @removeItem(item)
insertItem: (item, node, options = {}) ->
return if @destroyed
existingActionNumber = @actionMap.get(item)
if existingActionNumber > options.actionNumber |
Another action for this item is scheduled for the future, do it then instead of now. Actions added later enforce order, so we make this one a noop and let the later one have its proper effects. | @actions[options.actionNumber] = ->
else |
Another action has been scheduled for this item. It hasn't been done yet because its in the actionmap, but this insert is scheduled to happen after it. Skip it since its now defunct. | if existingActionNumber
@cancelExistingItemActions(item) |
Update the action number map to now reflect this new action which will go on the end of the queue. | @actionMap.set item, options.actionNumber
@actions[options.actionNumber] = ->
show = Batman.data node, 'show'
if typeof show is 'function'
show.call node, before: @siblingNode
else
if options.fragment
@fragment.appendChild node
else
$insertBefore @parentNode, node, @siblingNode
@actions[options.actionNumber].item = item
@processActionQueue()
cancelExistingItem: (item) ->
@cancelExistingItemActions(item)
@cancelExistingItemRender(item)
cancelExistingItemActions: (item) ->
oldActionNumber = @actionMap.get(item) |
Only remove actions which haven't been completed yet. | if oldActionNumber? && oldActionNumber >= @currentActionNumber
@actionMap.unset(item)
@actions[oldActionNumber] = false
@actionMap.unset item
cancelExistingItemRender: (item) ->
oldRenderer = @rendererMap.get(item)
if oldRenderer
oldRenderer.stop()
$removeNode(oldRenderer.node)
@rendererMap.unset item
processActionQueue: ->
return if @destroyed
unless @actionQueueTimeout |
Prevent the parent which will then be allowed when the timeout actually runs | @actionQueueTimeout = $setImmediate =>
return if @destroyed
delete @actionQueueTimeout
startTime = new Date
while (f = @actions[@currentActionNumber])?
delete @actions[@currentActionNumber]
@actionMap.unset f.item
f.call(@) if f
@currentActionNumber++
if @deferEvery && (new Date - startTime) > @deferEvery
return @processActionQueue()
if @fragment && @rendererMap.length is 0 && @fragment.hasChildNodes()
$insertBefore @parentNode, @fragment, @siblingNode
@fragment = document.createDocumentFragment()
if @currentActionNumber == @queuedActionNumber
@parentRenderer.allowAndFire 'rendered'
_nodeForItem: (item) ->
newNode = @prototypeNode.cloneNode(true)
@nodeMap.set(item, newNode)
newNode |
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])
has: (set, item) ->
return false unless set?
Batman.contains(set, item)
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)
interpolate: (string, interpolationKeypaths) ->
return undefined unless string?
values = {}
for k, v of interpolationKeypaths
values[k] = @findKey(v)[0]
if !values[k]?
Batman.developer.warn "Warning! Undefined interpolation key #{k} for interpolation", string
values[k] = ''
Batman.helpers.interpolate(string, values)
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, '') |
Test to see if it's possible to delete an expando from an element Fails in Internet Explorer | canDeleteExpando: try
div = document.createElement 'div'
delete div.test
catch e
Batman.canDeleteExpando = false
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"
cache = Batman.cache |
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 = elem[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 |
Also check that it's not a text node; IE can't set expandos on them | if elem.nodeType isnt 3
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] ||= {}
thisCache = thisCache[internalKey]
if data != undefined
thisCache[name] = 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]
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 = Batman.cache
id = elem[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 | 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 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 |
Mixins | mixins = Batman.mixins = new Batman.Object() |
Encoders | Batman.Encoders = {}
class Batman.Paginator extends Batman.Object
class @Cache
constructor: (@offset, @limit, @items) ->
@length = items.length
@reach = offset + limit
containsItemsForOffsetAndLimit: (offset, limit) ->
offset >= @offset and (offset + limit) <= @reach
itemsForOffsetAndLimit: (offset, limit) ->
return unless @containsItemsForOffsetAndLimit(offset, limit)
begin = offset-@offset
end = begin + limit
@items.slice(begin, end)
offset: 0
limit: 10
totalCount: 0
offsetFromPageAndLimit: (page, limit) -> Math.round((+page - 1) * limit)
pageFromOffsetAndLimit: (offset, limit) -> offset / limit + 1
toArray: ->
cache = @get('cache')
offset = @get('offset')
limit = @get('limit')
items = cache?.itemsForOffsetAndLimit(offset, limit)
@loadItemsForOffsetAndLimit(offset, limit) unless items
items or []
page: ->
@pageFromOffsetAndLimit(@get('offset'), @get('limit'))
pageCount: ->
Math.ceil(@get('totalCount') / @get('limit'))
previousPage: -> @set('page', @get('page')-1)
nextPage: -> @set('page', @get('page')+1)
loadItemsForOffsetAndLimit: (offset, limit) -> # override on subclasses or instances
updateCache: (offset, limit, items) ->
@set('cache', new Batman.Paginator.Cache(offset, limit, items))
@accessor 'toArray', @::toArray
@accessor 'offset', 'limit', 'totalCount'
get: Batman.Property.defaultAccessor.get
set: (key, value) -> Batman.Property.defaultAccessor.set.call(this, key, +value)
@accessor 'page',
get: @::page
set: (_,value) ->
value = +value
@set('offset', @offsetFromPageAndLimit(value, @get('limit')))
value
@accessor 'pageCount', @::pageCount
class Batman.ModelPaginator extends Batman.Paginator
cachePadding: 0
paddedOffset: (offset) ->
offset -= @cachePadding
if offset < 0 then 0 else offset
paddedLimit: (limit) ->
limit + @cachePadding * 2
loadItemsForOffsetAndLimit: (offset, limit) ->
params = @paramsForOffsetAndLimit(offset, limit)
params[k] = v for k,v of @params
@model.load params, (err, records) =>
unless err?
@updateCache(@offsetFromParams(params), @limitFromParams(params), records) |
override these to fetch records however you like: | paramsForOffsetAndLimit: (offset, limit) ->
offset: @paddedOffset(offset), limit: @paddedLimit(limit)
offsetFromParams: (params) -> params.offset
limitFromParams: (params) -> params.limit |
Export a few globals, and grab a reference to an object accessible from all contexts for use elsewhere.
In node, the container is the | Batman.container = if exports?
module.exports = Batman
global
else
window.Batman = Batman
window |
Optionally export global sugar. Not sure what to do with this. | Batman.exportHelpers = (onto) ->
for k in ['mixin', 'unmixin', 'route', 'redirect', 'typeOf', 'redirect', 'setImmediate']
onto["$#{k}"] = Batman[k]
onto
Batman.exportGlobals = () ->
Batman.exportHelpers(Batman.container)
|