Anno.js ======= 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). The MIT License (MIT) --------------------- 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 Creating an Anno object ----------------------- 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 Making a step-by-step tour -------------------------- 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 Hiding and showing annotations ------------------------------ `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 Customizing target ------------------ 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 = $("""
""") @_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 ------- 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: () -> $("
"+@contentFn()+"
") Semi-transparent overlay and other effects ------------------------------------------ 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: () -> $("
"). 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 Positioning ----------- 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' Customizing Buttons ------------------- 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 $("
"). append (b.buttonElem(this) for b in @buttonsFn()) AnnoButton ========== exports.AnnoButton = class AnnoButton constructor: (options) -> for key,val of options this[key]=val buttonElem: (anno) -> return $(""). 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() )