$ = require 'jquery'
require 'scrollintoview/jquery.scrollintoview.js'
exports.Anno = class Anno
Interactive step-by-step guides for web apps.
Anno.js is built to be absurdly extensible, but still works great out of the box (and looks damn fine doing it).
Copyright (c) 2013 Dan Fox
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
$ = require 'jquery'
require 'scrollintoview/jquery.scrollintoview.js'
exports.Anno = class Anno
An Anno object represents a single annotation on your page. Each Anno has a
target
jQuery selector and a content
string as well as many other
properties. For example:
pizzaAnno = new Anno({
target: '#pizza-list',
content: 'Choose your pizza from the list below.'
})
pizzaAnno.show()
This constructor lets you override these properties and uses defaults for anything you don’t specify.
You can supply either a single object, or a list of objects.
constructor: (arg) ->
if arg.__proto__ is Array.prototype
options = arg.shift() # remove the first element from arg.
others = arg
else
options = arg
if options instanceof Anno # first argument is an object
console.warn 'Anno constructor parameter is already an Anno object.'
if not options?
console.warn "new Anno() created with no options. It's recommended"+
" to supply at least target and content."
for key,val of options when key in ['chainTo', 'start', 'show',
'hide', 'hideAnno', 'chainSize', 'chainIndex', 'version']
console.warn "Anno: Overriding '#{key}' is not recommended. Can "+
"you override a delegated function instead?"
for key,val of options
this[key]=val
if others?.length > 0
@chainTo( new Anno(others) )
return
In practise, I usually like to specify buttons
and position
. You may
also want to override onShow
and onHide
callbacks, className
and even
overlayElem()
for complete control.
If you find yourself setting the same property on every Anno object you create, you can set default values at the top of your script that will apply to every Anno object from then onwards.
@setDefaults: (options) ->
for key,val of options
Anno::[key] = val
Individual Anno objects can be chained together to make a sequential tour.
chainTo: (obj) ->
if obj?
if not @_chainNext? # end of the chain, add obj to the end.
@_chainNext = if obj instanceof Anno then obj else new Anno(obj)
@_chainNext._chainPrev = this
else # pass the obj further along
@_chainNext.chainTo(obj)
else
console.error "Can't chainTo a null object."
return this
_chainNext: null
_chainPrev: null
Long Anno chains can also be made by passing a list to the constructor:
var annoTour = new Anno([
{
target: '.pizza-list',
content: 'Choose your pizza from the list below.'
}, {
target: '#address-form',
content: "Enter your address and we'll deliver your pizza",
position: 'left'
}
])
Note, the annoTour
variable still only points to the single Anno object
(for .pizza-list
), we’ve just chained another one onto it anonymously.
@chain: (array) ->
console.warn 'Anno.chain([...]) is deprecated. Use '+
'`new Anno([...])` instead.'
return new Anno(array)
chainSize: () ->
if @_chainNext? then @_chainNext.chainSize() else 1+@chainIndex()
anno.chainIndex(x)
gets the xth object in the chain, anno.chainIndex()
gets the current index.
chainIndex: (index) ->
if index?
(find = (curr, i, u) ->
if curr?
ci = curr.chainIndex()
if 0 <= ci < i then find(curr._chainNext, i, u)
else if i < ci <= u then find(curr._chainPrev, i, u)
else if ci is i then curr
else console.error "Couldn't switch to index '#{i}'. Chain size "+
"is '#{u}'"
)(this, index, @chainSize())
else
if @_chainPrev? then 1+@_chainPrev.chainIndex() else 0
anno.show()
displays your annotation on top of a nice overlay and executes
a callback. All methods used here can be overridden in the same way we
changed the content
property.
Animations are all done with 300ms CSS transitions, so you can change your UI without touching any javascript.
show: () ->
$target = @targetFn()
if @_annoElem?
console.warn "Anno elem for '#{@target}' has already been "+
"generated. Did you call show() twice?"
@_annoElem = @annoElem()
@emphasiseTarget()
@showOverlay()
insert into DOM!
$target.after(@_annoElem) # TODO warn if already inserted once
@_annoElem.addClass('anno-target-'+@arrowPositionFn())
@positionAnnoElem()
setTimeout (() => @_annoElem.removeClass('anno-hidden')), 50
$target.scrollintoview()
setTimeout (() => @_annoElem.scrollintoview()) , 300
lastButton = @_annoElem.find('button').last()
if @rightArrowClicksLastButton
lastButton.keydown( (evt) ->
if evt.keyCode is 39 then $(this).click() # right arrow
)
if @autoFocusLastButton and $target.find(':focus').length is 0
lastButton.focus()
@_returnFromOnShow = @onShow(this, $target, @_annoElem)
return this
start: () -> @show() # `tour.start()` sounds nicer than `tour.show()`
rightArrowClicksLastButton: true
autoFocusLastButton: true
The onShow
callback does nothing by default, but can be very useful when
overridden (e.g. to register a click listener on the target element.)
Whatever value you return from the onShow
function will get passed to the
onHide
callback. This can be used to unbind event listeners.
onShow: (anno, $target, $annoElem) ->
_returnFromOnShow = null
Hiding is done in two stages so that you can re-use one overlay element for a long chain of Anno’s.
hide: () ->
@hideAnno()
setTimeout @hideOverlay, 50
return this
hideAnno: () ->
if @_annoElem?
@_annoElem.addClass('anno-hidden')
@deemphasiseTarget()
@onHide(this, @targetFn(), @_annoElem, @_returnFromOnShow)
((annoEl) ->
setTimeout (() -> annoEl.remove()), 300
)(@_annoElem) # captures @_annoElem in the timeout function only
@_annoElem = null
else
console.warn "Can't hideAnno() for '#{@target}' when @_annoElem "+
"is null. Did you call hideAnno() twice?"
return this
onHide: (anno, $target, $annoElem, returnFromOnShow) ->
switchTo
displays another Anno and reuses the old overlay.
switchTo: (otherAnno) ->
if otherAnno?
@hideAnno() # TODO: prevent this call if current Anno isn't shown
otherAnno.show()
else
console.warn "Can't switchTo a null object. Hiding instead."
@hide() # removes overlay to recover from a programmer mistake
switchToChainNext: () -> @switchTo @_chainNext
switchToChainPrev: () -> @switchTo @_chainPrev
Specify a target
jQuery selector to link your annotation to the DOM.
target: 'h1'
targetFn()
is used internally to return the first element matching your
target
selector (wrapped as a jQuery object).
targetFn: () ->
if typeof @target is 'string'
.anno-placeholder is a clone to prevent text wrapping
r = $(@target).filter(':not(.anno-placeholder)')
if r.length is 0
console.error "Couldn't find Anno.target '#{@target}'."
if r.length > 1
console.warn "Anno target '#{@target}' matched #{r.length} "+
"elements. Targeting the first one."
r.first()
else if @target instanceof jQuery
if @target.length > 1
console.warn "Anno jQuery target matched #{@target.length} "+
"elements. Targeting the first one."
return @target.first()
else if @target instanceof HTMLElement
$(@target)
else if typeof @target is 'function'
@target()
else
console.error "Unrecognised Anno.target. Please supply a jQuery "+
"selector string, a jQuery object, a raw DOM element or a "+
"function returning a jQuery element. target:"
console.error @target
annoElem()
generates the jQuery object that will be inserted into the DOM.
annoElem: () -> #TODO:should this encapsulate the re-use of one object?
@_annoElem = $("""<div class='anno anno-hidden #{@className}'>
<div class='anno-inner'> <div class='anno-arrow'></div> </div>
</div>""")
@_annoElem.find('.anno-inner').
append( @contentElem() ).
append( @buttonsElem() ) # <- jquery elements, not HTML strings.
return @_annoElem
_annoElem: null
CSS classes can be included, e.g. .anno-width-150, 175, 200, 250 (default 300)
className: ''
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
Override this instead of content
if you don’t know the content in advance
contentFn: () -> @content
contentElem: () -> $("<div class='anno-content'>"+@contentFn()+"</div>")
showOverlay: () ->
if $('.anno-overlay').length is 0 # TODO mention pointer-events:none
$('body').append($e = @overlayElem().addClass 'anno-hidden')
setTimeout (() -> $e.removeClass 'anno-hidden'), 10
else
$('.anno-overlay').replaceWith @overlayElem()
overlayElem: () ->
$("<div class='anno-overlay #{@overlayClassName}'></div>").
click( (evt) => @overlayClick.call(this, this, evt) )
.anno-hidden
is automatically added to the HTML, then removed to give
the fade in effect.
overlayClassName: ''
overlayClick: (anno, evt) -> anno.hide()
hideOverlay: () ->
$('.anno-overlay').addClass 'anno-hidden'
setTimeout (() -> $('.anno-overlay').remove()), 300
emphasiseTarget: ($target = @targetFn()) ->
@_undoEmphasise = [] # crucial.
$target.closest(':scrollable').on 'mousewheel', (evt) ->
evt.preventDefault()
evt.stopPropagation()
@_undoEmphasise.push ($t) ->
$t.closest(':scrollable').off('mousewheel')
if $target.css('position') is 'static'
ensures that the jquery :first selector in targetFn works.
$target.after(placeholder = $target.clone()
.addClass('anno-placeholder'))
do (placeholder) => @_undoEmphasise.push () -> placeholder.remove()
startposition = $target.prop('style').position
do (startposition) =>
@_undoEmphasise.push ($t) -> $t.css position:startposition
$target.css( position:'absolute' )
If switching to position:absolute
has caused a dimension
collapse, manually set H/W.
if $target.outerWidth() isnt placeholder.outerWidth()
Find $target’s inline style, make an undo function
origwidth = $target.prop('style').width
do (origwidth) =>
@_undoEmphasise.push ($t) -> $t.css width:origwidth
$target.css('width', placeholder.outerWidth())
if $target.outerHeight() isnt placeholder.outerHeight()
origheight = $target.prop('style').height
do (origheight) =>
@_undoEmphasise.push ($t) -> $t.css height:origheight
$target.css('height', placeholder.outerHeight())
If switching to position:absolute
has caused a position change,
manually set it too
ppos = placeholder.position()
tpos = $target.position()
if tpos.top isnt ppos.top
origtop = $target.prop('style').top
do (origtop) => @_undoEmphasise.push ($t) -> $t.css top:origtop
$target.css('top', ppos.top)
if tpos.left isnt ppos.left
origleft = $target.prop('style').left
do (origleft) => @_undoEmphasise.push ($t) -> $t.css left:origleft
$target.css('left', ppos.left)
if $target.css('backgroundColor') is 'rgba(0, 0, 0, 0)' or
$target.css('backgroundColor') is 'transparent'
console.warn "Anno.js target '#{@target}' has a transparent bg; "+
"filling it white temporarily."
origbg = $target.prop('style').background
do (origbg) => @_undoEmphasise.push ($t) -> $t.css background:origbg
$target.css( background: 'white')
origzindex = $target.prop('style').zIndex
do (origzindex) => @_undoEmphasise.push ($t) -> $t.css zIndex:origzindex
$target.css( zIndex:'1001' )
return $target
_undoEmphasise: [] # list of functions to undo emphasiseTarget()
deemphasiseTarget: () ->
$target = @targetFn()
fn($target) for fn in @_undoEmphasise
return $target
The position
property decides where your annotation will be displayed.
It may be any of top
, left
, bottom
, right
, center-top
,
center-left
, center-bottom
or center-right
.
Alternatively, you can supply a hash of CSS attributes:
position = {
top: '10px',
left: '57px'
}
If you omit the position
attribute entirely, Anno will use its best guess.
position: null
positionAnnoElem()
sets the CSS of the Anno element so that it appears
next to your target in a sensible way. Must be called after DOM insertion.
positionAnnoElem: (annoEl = @_annoElem) ->
pos = @positionFn()
$targetEl = @targetFn()
offset = $targetEl.position()
switch pos
when 'top', 'bottom'
annoEl.css(left: offset.left+'px')
when 'center-top', 'center-bottom'
annoEl.css(left: offset.left+($targetEl.outerWidth()/2 -
annoEl.outerWidth()/2)+'px')
when 'left', 'right'
annoEl.css(top: offset.top+'px')
when 'center-left', 'center-right'
annoEl.css(top: offset.top+($targetEl.outerHeight()/2 -
annoEl.outerHeight()/2)+'px')
switch pos
when 'top', 'center-top'
annoEl.css( top: offset.top-annoEl.outerHeight()+'px')
when 'bottom', 'center-bottom'
annoEl.css( top: offset.top+$targetEl.outerHeight()+'px')
when 'left', 'center-left'
annoEl.css(left: offset.left-annoEl.outerWidth()+'px')
when 'right', 'center-right'
annoEl.css(left: offset.left+$targetEl.outerWidth()+'px')
else
if pos.left? or pos.right? or pos.top? or pos.bottom?
annoEl.css(pos)
else
console.error "Unrecognised position: '#{pos}'"
return annoEl
positionFn()
returns the position
property or tries to guess one if you
left it blank.
positionFn: () -> # Must be called after DOM insertion.
if @position?
return @position
else if @_annoElem?
$target = @targetFn()
$container = $target.closest(':scrollable')
$container = $('body') if $container.length is 0
targetOffset = $target.offset() # both relative to document
containerOffset = $container.offset()
targetBounds =
left: targetOffset.left - containerOffset.left
top: targetOffset.top - containerOffset.top
targetBounds = dist from edge of $container to side of elem
targetBounds.right = targetBounds.left + $target.outerWidth()
targetBounds.bottom = targetBounds.top + $target.outerHeight()
viewBounds = # TODO: why is the `or` keyword there??
w: $container.width() or $container.width()
h: $container.height() or $container.height()
annoBounds =
w: @_annoElem.outerWidth()
h: @_annoElem.outerHeight()
bad = []
if annoBounds.w > targetBounds.left
bad = bad.concat ['left', 'center-left']
if annoBounds.h > targetBounds.top
bad = bad.concat ['top', 'center-top']
if annoBounds.w + targetBounds.right > viewBounds.w
bad = bad.concat ['right', 'center-right']
if annoBounds.h + targetBounds.bottom > viewBounds.h
bad = bad.concat ['bottom', 'center-bottom']
allowed = Anno.preferredPositions.filter (p) -> p not in bad
if allowed.length is 0
console.error "Anno couldn't guess a position for '#{@target}'. "+
"Please supply one in the constructor."
else
console.warn "Anno: guessing position:'#{allowed[0]}' for "+
"'#{@target}'. Possible Anno.preferredPositions: [#{allowed}]."
return @position = allowed[0] # store this value for later
When there are different positions that the Anno element could by displayed,
positionFn()
chooses the first one available in Anno.preferredPositions
.
@preferredPositions = ['bottom', 'right', 'left', 'top',
'center-bottom', 'center-right', 'center-left', 'center-top']
arrowPositionFn()
returns which way the arrow should point.
This is normally just the opposite of the anno position.
May only be called after DOM insertion.
arrowPositionFn: () ->
if @arrowPosition?
return @arrowPosition
else if typeof @positionFn() is 'string'
return {
'top': 'bottom'
'center-top': 'center-bottom'
'left': 'right'
'center-left' : 'center-right'
'right' : 'left'
'center-right' : 'center-left'
'bottom': 'top'
'center-bottom' : 'center-top'
}[@positionFn()]
else
pos =
l : parseInt(@positionFn().left, 10)
t : parseInt(@positionFn().top, 10)
if Math.abs(pos.l) > Math.abs(pos.t)
r = if pos.l < 0 then 'center-right' else 'center-left'
else
r = if pos.t < 0 then 'center-bottom' else 'center-top'
console.warn "Guessing arrowPosition:'#{r}' for #{@target}. " +
"Include this in your constructor for consistency."
return r
Override this if you’ve supplied a CSS hash as the position
property. Can be
any of top
, left
, etc.
arrowPosition: null # TODO replace 'arrowPosition' with 'arrowDirection'
By default, Annotations have a single button, filled with default values from
the AnnoButton
class.
buttons: [ {} ]
returns a list of AnnoButton
objects
buttonsFn: () ->
if @buttons instanceof Array
@buttons.map (b) -> new AnnoButton(b)
else
[new AnnoButton(@buttons)] # `@buttons` is a single hash here
buttonsElem()
produces the HTML for all those buttons (as a jQuery object).
buttonsElem: () ->
return $("<div class='anno-btn-container'></div>").
append (b.buttonElem(this) for b in @buttonsFn())
exports.AnnoButton = class AnnoButton
constructor: (options) ->
for key,val of options
this[key]=val
buttonElem: (anno) ->
return $("<button class='anno-btn'></button>").
html( @textFn(anno) ).
addClass( @className ).
click( (evt) => @click.call(anno, anno, evt) )
textFn: (anno) ->
if @text? then @text
else if anno._chainNext? then 'Next' else 'Done'
text: null
className: ''
click
is called when your button is clicked. Note, the this
keyword is
bound to the parent Anno object.
click: (anno, evt) ->
if anno._chainNext?
anno.switchToChainNext()
else
anno.hide()
These are some handy presets that you can use by adding AnnoButton.NextButton
to your Anno object’s buttons
list.
@NextButton: new AnnoButton(
text: 'Next'
click: () -> @switchToChainNext()
)
@DoneButton: new AnnoButton({ text: 'Done' , click: () -> @hide() })
@BackButton: new AnnoButton(
text: 'Back'
className: 'anno-btn-low-importance'
click: () -> @switchToChainPrev()
)