| opentip.coffee | |
|---|---|
| #More info at www.opentip.org Copyright (c) 2012, Matias Meno  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. # |  | 
| OpentipUsage: or externally: For a full documentation, please visit www.opentip.org | class Opentip
  STICKS_OUT_TOP: 1
  STICKS_OUT_BOTTOM: 2
  STICKS_OUT_LEFT: 1
  STICKS_OUT_RIGHT: 2
  class:
    container: "opentip-container"
    opentip: "opentip"
    content: "content"
    loadingIndicator: "loading-indicator"
    close: "close"
    goingToHide: "going-to-hide"
    hidden: "hidden"
    hiding: "hiding"
    goingToShow: "going-to-show"
    showing: "showing"
    visible: "visible"
    loading: "loading"
    ajaxError: "ajax-error"
    fixed: "fixed"
    showEffectPrefix: "show-effect-"
    hideEffectPrefix: "hide-effect-"
    stylePrefix: "style-"
   | 
| Sets up and configures the tooltip but does not build the html elements. 
 |   constructor: (element, content, title, options) ->
    @id = ++Opentip.lastId
    @debug "Creating Opentip."
    @adapter = Opentip.adapter | 
| Add the ID to the element |     elementsOpentips = @adapter.data(element, "opentips") || [ ]
    elementsOpentips.push this
    @adapter.data element, "opentips", elementsOpentips
    @triggerElement = @adapter.wrap element
    throw new Error "You can't call Opentip on multiple elements." if @triggerElement.length > 1
    throw new Error "Invalid element." if @triggerElement.length < 1 | 
| AJAX |     @loaded = no
    @loading = no
    @visible = no
    @waitingToShow = no
    @waitingToHide = no | 
| Some initial values |     @currentPosition = left: 0, top: 0
    @dimensions = width: 100, height: 50
    @content = ""
    @redraw = on
    @currentObservers =
      showing: no
      visible: no
      hiding: no
      hidden: no | 
| Make sure to not overwrite the users options object |     options = @adapter.clone options
    if typeof content == "object"
      options = content
      content = title = undefined
    else if typeof title == "object"
      options = title
      title = undefined | 
| Now build the complete options object from the styles |     options.title = title if title?
    @setContent content if content?
    options.style = Opentip.defaultStyle unless options.style | 
| All options are based on the standard style |     styleOptions = @adapter.extend { }, Opentip.styles.standard
    optionSources = [ ] | 
| All options are based on the standard style |     optionSources.push Opentip.styles.standard
    optionSources.push Opentip.styles[options.style] unless options.style == "standard"
    optionSources.push options
    options = @adapter.extend { }, optionSources... | 
| Deep copying the hideTriggers array |     options.hideTriggers = (hideTrigger for hideTrigger in options.hideTriggers)
    options.hideTriggers.push options.hideTrigger if options.hideTrigger | 
| Sanitize all positions |     options[prop] = new Opentip.Joint(options[prop]) for prop in [
      "tipJoint"
      "targetJoint"
      "stem"
    ] when options[prop] and typeof options[prop] == "string" | 
| If the url of an Ajax request is not set, get it from the link it's attached to. |     if options.ajax and (options.ajax == on or not options.ajax)
      if @adapter.tagName(@triggerElement) == "A"
        options.ajax = @adapter.attr @triggerElement, "href"
      else 
        options.ajax = off | 
| If the event is 'click', no point in following a link |     if options.showOn == "click" && @adapter.tagName(@triggerElement) == "A"
      @adapter.observe @triggerElement, "click", (e) ->
        e.preventDefault()
        e.stopPropagation()
        e.stopped = yes | 
| Doesn't make sense to use a target without the opentip being fixed |     options.fixed = yes if options.target
    options.stem = new Opentip.Joint(options.tipJoint) if options.stem == yes
    if options.target == yes
      options.target = @triggerElement
    else if options.target
      options.target = @adapter.wrap options.target
    @currentStem = options.stem
    unless options.delay?
      options.delay = if options.showOn == "mouseover" then 0.2 else 0
    unless options.targetJoint?
      options.targetJoint = new Opentip.Joint(options.tipJoint).flip() | 
| Used to show the opentip obviously |     @showTriggersWhenHidden = [ ] | 
| Those ensure that opentip doesn't disappear when hovering other related elements |     @showTriggersWhenVisible = [ ] | 
| Elements that hide Opentip |     @hideTriggers = [ ] | 
| The obvious showTriggerELementWhenHidden is the options.showOn |     if options.showOn and options.showOn != "creation"
      @showTriggersWhenHidden.push
        element: @triggerElement
        event: options.showOn
    @options = options | 
| Build the HTML elements when the dom is ready. |     @adapter.domReady => @_init() | 
| Initializes the tooltip by creating the container and setting up the event listeners. This does not yet create all elements. They are created when the tooltip actually shows for the first time. This function activates the tooltip as well. |   _init: ->
    @_buildContainer()
    for hideTrigger, i in @options.hideTriggers
      hideTriggerElement = null
      hideOn = if @options.hideOn instanceof Array then @options.hideOn[i] else @options.hideOn
      if typeof hideTrigger == "string"
        switch hideTrigger
          when "trigger"
            hideOn = hideOn || "mouseout"
            hideTriggerElement = @triggerElement
          when "tip"
            hideOn = hideOn || "mouseover"
            hideTriggerElement = @container
          when "target"
            hideOn = hideOn || "mouseover"
            hideTriggerElement = this.options.target
          when "closeButton" | 
| The close button gets handled later |           else
            throw new Error "Unknown hide trigger: #{hideTrigger}."
      else
        hideOn = hideOn || "mouseover"
        hideTriggerElement = @adapter.wrap hideTrigger
      if hideTriggerElement
        @hideTriggers.push
          element: hideTriggerElement
          event: hideOn
        if hideOn == "mouseout" | 
| When the hide trigger is mouseout, we have to attach a mouseover trigger to that element, so the tooltip doesn't disappear when hovering child elements. (Hovering children fires a mouseout mouseover event) |           @showTriggersWhenVisible.push
            element: hideTriggerElement
            event: "mouseover"
    @bound = { }
    @bound[methodToBind] = (do (methodToBind) => return => @[methodToBind].apply this, arguments) for methodToBind in [
      "prepareToShow"
      "prepareToHide"
      "show"
      "hide"
      "reposition"
    ]
    @activate()
    @prepareToShow() if @options.showOn == "creation" | 
| This just builds the opentip container, which is the absolute minimum to attach events to it. The actual creation of the elements is in buildElements() |   _buildContainer: ->
    @container = @adapter.create """<div id="opentip-#{@id}" class="#{@class.container} #{@class.hidden} #{@class.stylePrefix}#{@options.className}"></div>"""
    @adapter.css @container, position: "absolute"
    @adapter.addClass @container, @class.loading if @options.ajax
    @adapter.addClass @container, @class.fixed if @options.fixed
    @adapter.addClass @container, "#{@class.showEffectPrefix}#{@options.showEffect}" if @options.showEffect
    @adapter.addClass @container, "#{@class.hideEffectPrefix}#{@options.hideEffect}" if @options.hideEffect | 
| Builds all elements inside the container and put the container in body. |   _buildElements: -> | 
| The actual content will be set by  |     @tooltipElement = @adapter.create """<div class="#{@class.opentip}"><header></header><div class="#{@class.content}"></div></div>"""
    @backgroundCanvas = @adapter.create """<canvas style="position: absolute;"></canvas>"""
    headerElement = @adapter.find @tooltipElement, "header"
    if @options.title | 
| Create the title element and append it to the header |       titleElement = @adapter.create """<h1></h1>"""
      @adapter.update titleElement, @options.title, @options.escapeTitle
      @adapter.append headerElement, titleElement
    if @options.ajax
      @adapter.append @tooltipElement, @adapter.create """<div class="#{@class.loadingIndicator}"><span>Loading...</span></div>"""
    if "closeButton" in @options.hideTriggers
      @closeButtonElement = @adapter.create """<a href="javascript:undefined;" class="#{@class.close}"><span>Close</span></a>"""
      @adapter.append headerElement, @closeButtonElement | 
| Now put the tooltip and the canvas in the container and the container in the body |     @adapter.append @container, @backgroundCanvas
    @adapter.append @container, @tooltipElement
    @adapter.append document.body, @container | 
| Sets the content and updates the HTML element if currently visible This can be a function or a string. The function will be executed, and the result used as new content of the tooltip. |   setContent: (@content) -> @_updateElementContent() if @visible | 
| Actually updates the content. If content is a function it is evaluated here. |   _updateElementContent: ->
    contentDiv = @adapter.find @container, ".content"
    if contentDiv?
      if typeof @content == "function"
        @debug "Executing content function."
        @content = @content this
      @adapter.update contentDiv, @content, @options.escapeContent
    @_storeAndLockDimensions()
    @reposition() | 
| Sets width auto to the element so it uses the appropriate width, gets the dimensions and sets them so the tolltip won't change in size (which can be annoying when the tooltip gets too close to the browser edge) |   _storeAndLockDimensions: ->
    prevDimension = @dimensions
    @adapter.css @container,
      width: "auto"
      left: "0px" # So it doesn't force wrapping
      top: "0px"
    @dimensions = @adapter.dimensions @container
    @adapter.css @container,
      width: "#{@dimensions.width}px"
      top: "#{@currentPosition.top}px"
      left: "#{@currentPosition.left}px"
    unless @_dimensionsEqual @dimensions, prevDimension
      @redraw = on 
      @_draw() | 
| Sets up appropriate observers |   activate: ->
    @_setupObservers "-showing", "-visible", "hidden", "hiding" | 
| Hides the tooltip and sets up appropriate observers |   deactivate: ->
    @debug "Deactivating tooltip."
    @hide() | 
| If a state starts with a minus all observers are removed instead of set. |   _setupObservers: (states...) ->
    for state in states
      removeObserver = no
      if state.charAt(0) == "-"
        removeObserver = yes
        state = state.substr 1 # Remove leading - | 
| Do nothing if the state is already achieved |       continue if @currentObservers[state] is not removeObserver
      @currentObservers[state] = not removeObserver
      observeOrStop = (args...) =>
        if removeObserver then @adapter.stopObserving args...
        else @adapter.observe args...
      switch state
        when "showing" | 
| Setup the triggers to hide the tip |           for trigger in @hideTriggers
            observeOrStop trigger.element, trigger.event, @bound.prepareToHide | 
| Start listening to window changes |           observeOrStop (if document.onresize? then document else window), "resize", @bound.reposition
          observeOrStop window, "scroll", @bound.reposition
        when "visible" | 
| Most of the observers have already been handled by "showing" Add the triggers that make sure opentip doesn't hide prematurely |           for trigger in @showTriggersWhenVisible
            observeOrStop trigger.element, trigger.event, @bound.prepareToShow
        when "hiding" | 
| Setup the triggers to show the tip |           for trigger in @showTriggersWhenHidden
            observeOrStop trigger.element, trigger.event, @bound.prepareToShow
          
        when "hidden" | 
| Nothing to do since all observers are setup in "hiding" |         else
          throw new Error "Unknown state: #{state}"
    null # No unnecessary array collection
  prepareToShow: ->
    @_abortHiding()
    return if @visible
    @debug "Showing in #{@options.delay}s."
    Opentip._abortShowingGroup @options.group if @options.group
    @preparingToShow = true | 
| Even though it is not yet visible, I already attach the observers, so the tooltip won't show if a hideEvent is triggered. |     @_setupObservers "-hidden", "-hiding", "showing" | 
| Making sure the tooltip is at the right position as soon as it shows |     @_followMousePosition()
    @reposition()
    @_showTimeoutId = @setTimeout @bound.show, @options.delay || 0
  show: ->
    @_clearTimeouts()
    return if @visible
    return @deactivate() unless @_triggerElementExists()
    @debug "Showing now."
    Opentip._hideGroup @options.group if @options.group
    @visible = yes
    @preparingToShow = no
    @_buildElements() unless @tooltipElement?
    @_updateElementContent()
    @_loadAjax() if @options.ajax and (not @loaded or not @options.ajaxCache)
    @_searchAndActivateCloseButtons()
    @_startEnsureTriggerElement()
    @adapter.css @container, zIndex: Opentip.lastZIndex++ | 
| The order is important here! Do not reverse. |     @_setupObservers "-hidden", "-hiding", "showing", "visible"
    @reposition()
    @adapter.removeClass @container, @class.hiding
    @adapter.removeClass @container, @class.hidden
    @adapter.addClass @container, @class.goingToShow
    @setCss3Style @container, transitionDuration: "0s"
    @defer =>
      @adapter.removeClass @container, @class.goingToShow
      @adapter.addClass @container, @class.showing
      delay = 0
      delay = @options.showEffectDuration if @options.showEffect and @options.showEffectDuration
      @setCss3Style @container, transitionDuration: "#{delay}s"
      @_visibilityStateTimeoutId = @setTimeout =>
        @adapter.removeClass @container, @class.showing
        @adapter.addClass @container, @class.visible
      , delay
      @_activateFirstInput() | 
| Just making sure the canvas has been drawn initially. It could happen that the canvas isn't drawn yet when reposition is called once before the canvas element has been created. If the position doesn't change after it will never call @_draw() again. |     @_draw()
  _abortShowing: ->
    if @preparingToShow
      @debug "Aborting showing."
      @_clearTimeouts()
      @_stopFollowingMousePosition()
      @preparingToShow = false
      @_setupObservers "-showing", "-visible", "hiding", "hidden"
  prepareToHide: ->
    @_abortShowing()
    return unless @visible
    @debug "Hiding in #{@options.hideDelay}s"
    @preparingToHide = yes | 
| We start observing even though it is not yet hidden, so the tooltip does not disappear when a showEvent is triggered. |     @_setupObservers "-showing", "-visible", "-hidden", "hiding"
    @_hideTimeoutId = @setTimeout @bound.hide, @options.hideDelay
  hide: ->
    @_clearTimeouts()
    return unless @visible
    @debug "Hiding!"
    @visible = no
    @preparingToHide = no
    @_stopEnsureTriggerElement()
    @_setupObservers "-showing", "-visible", "hiding", "hidden"
    @_stopFollowingMousePosition() unless @options.fixed
 
    @adapter.removeClass @container, @class.visible
    @adapter.removeClass @container, @class.showing
    @adapter.addClass @container, @class.goingToHide
    @setCss3Style @container, transitionDuration: "0s"
    @defer =>
      @adapter.removeClass @container, @class.goingToHide
      @adapter.addClass @container, @class.hiding
      hideDelay = 0
      hideDelay = @options.hideEffectDuration if @options.hideEffect and @options.hideEffectDuration
      @setCss3Style @container, { transitionDuration: "#{hideDelay}s" }
      @_visibilityStateTimeoutId = @setTimeout =>
        @adapter.removeClass @container, @class.hiding
        @adapter.addClass @container, @class.hidden
        @setCss3Style @container, { transitionDuration: "0s" }
      , hideDelay
  _abortHiding: ->
    if @preparingToHide
      @debug "Aborting hiding."
      @_clearTimeouts()
      @preparingToHide = no
      @_setupObservers "-hiding", "showing", "visible"
  reposition: (e) ->
    e ?= @lastEvent
    position = @getPosition e
    return unless position?
    {position, stem} = @_ensureViewportContainment e, position | 
| If the position didn't change, no need to do anything |     return if @_positionsEqual position, @currentPosition | 
| The only time the canvas has to bee redrawn is when the stem changes. |     @redraw = on unless !@options.stem or stem.eql @currentStem
    @currentPosition = position
    @currentStem = stem | 
| _draw() itself tests if it has to be redrawn. |     @_draw()
    @adapter.css @container, { left: "#{position.left}px", top: "#{position.top}px" } | 
| Following is a redraw fix, because I noticed some drawing errors in some browsers when tooltips where overlapping. |     @defer =>
      rawContainer = @adapter.unwrap @container | 
| I chose visibility instead of display so that I don't interfere with appear/disappear effects. |       rawContainer.style.visibility = "hidden"
      redrawFix = rawContainer.offsetHeight
      rawContainer.style.visibility = "visible"
  getPosition: (e, tipJoint, targetJoint, stem) ->
    tipJoint ?= @options.tipJoint
    targetJoint ?= @options.targetJoint
    position = { }
    if @options.target | 
| Position is fixed |       targetPosition = @adapter.offset @options.target
      targetDimensions = @adapter.dimensions @options.target
      position = targetPosition
      if targetJoint.right | 
| For wrapping inline elements, left + width does not give the right border, because left is where the element started, not its most left position. |         unwrappedTarget = @adapter.unwrap @options.target
        if unwrappedTarget.getBoundingClientRect? | 
| TODO: make sure this actually works. |           position.left = unwrappedTarget.getBoundingClientRect().right + (window.pageXOffset ? document.body.scrollLeft)
        else | 
| Well... browser doesn't support it |           position.left += targetDimensions.width
      else if targetJoint.center | 
| Center |         position.left += Math.round targetDimensions.width / 2
      if targetJoint.bottom
        position.top += targetDimensions.height
      else if targetJoint.middle | 
| Middle |         position.top += Math.round targetDimensions.height / 2
      if @options.borderWidth
        if @options.tipJoint.left
          position.left += @options.borderWidth
        if @options.tipJoint.right
          position.left -= @options.borderWidth
        if @options.tipJoint.top
          position.top += @options.borderWidth
        else if @options.tipJoint.bottom
          position.top -= @options.borderWidth
        
    else | 
| Follow mouse |       @lastEvent = e if e?
      mousePosition = @adapter.mousePosition e
      return unless mousePosition?
      position = top: mousePosition.y, left: mousePosition.x
    if @options.autoOffset
      stemLength = if @options.stem then @options.stemLength else 0 | 
| If there is as stem offsets dont need to be that big if fixed. |       offsetDistance = if stemLength and @options.fixed then 2 else 10 | 
| Corners can be closer but when middle or center they are too close |       additionalHorizontal = if tipJoint.middle and not @options.fixed then 15 else 0
      additionalVertical = if tipJoint.center and not @options.fixed then 15 else 0
      if tipJoint.right then position.left -= offsetDistance + additionalHorizontal
      else if tipJoint.left then position.left += offsetDistance + additionalHorizontal
      if tipJoint.bottom then position.top -= offsetDistance + additionalVertical
      else if tipJoint.top then position.top += offsetDistance + additionalVertical
      if stemLength
        stem ?= @options.stem
        if stem.right then position.left -= stemLength
        else if stem.left then position.left += stemLength
        if stem.bottom then position.top -= stemLength
        else if stem.top then position.top += stemLength
    position.left += @options.offset[0]
    position.top += @options.offset[1]
    if tipJoint.right then position.left -= @dimensions.width
    else if tipJoint.center then position.left -= Math.round @dimensions.width / 2
    if tipJoint.bottom then position.top -= @dimensions.height
    else if tipJoint.middle then position.top -= Math.round @dimensions.height / 2
    position
  _ensureViewportContainment: (e, position) ->
    stem = @options.stem
    originals = {
      position: position
      stem: stem
    } | 
| Sometimes the element is theoretically visible, but an effect is not yet showing it. So the calculation of the offsets is incorrect sometimes, which results in faulty repositioning. |     return originals unless @visible and position
    
    sticksOut = @_sticksOut position
    return originals unless sticksOut[0] or sticksOut[1]
    tipJoint = new Opentip.Joint @options.tipJoint
    targetJoint = new Opentip.Joint @options.targetJoint if @options.targetJoint
    scrollOffset = @adapter.scrollOffset()
    viewportDimensions = @adapter.viewportDimensions() | 
| The opentip's position inside the viewport |     viewportPosition = [
      position.left - scrollOffset[0]
      position.top - scrollOffset[1]
    ]
    needsRepositioning = no
    if viewportDimensions.width >= @dimensions.width | 
| Well if the viewport is smaller than the tooltip there's not much to do |       if sticksOut[0]
        needsRepositioning = yes
        switch sticksOut[0]
          when @STICKS_OUT_LEFT
            tipJoint.setHorizontal "left"
            targetJoint.setHorizontal "right" if @options.targetJoint
          when @STICKS_OUT_RIGHT
            tipJoint.setHorizontal "right"
            targetJoint.setHorizontal "left" if @options.targetJoint
    if viewportDimensions.height >= @dimensions.height | 
| Well if the viewport is smaller than the tooltip there's not much to do |       if sticksOut[1]
        needsRepositioning = yes
        switch sticksOut[1]
          when @STICKS_OUT_TOP
            tipJoint.setVertical "top"
            targetJoint.setVertical "bottom" if @options.targetJoint
          when @STICKS_OUT_BOTTOM
            tipJoint.setVertical "bottom"
            targetJoint.setVertical "top" if @options.targetJoint
    return originals unless needsRepositioning | 
| Needs to reposition |  | 
| TODO: actually handle the stem here |     stem = tipJoint if @options.stem
    position = @getPosition e, tipJoint, targetJoint, stem
    newSticksOut = @_sticksOut position
    revertedX = no
    revertedY = no
    if newSticksOut[0] and (newSticksOut[0] isnt sticksOut[0]) | 
| The tooltip changed sides, but now is sticking out the other side of the window. |       revertedX = yes
      tipJoint.setHorizontal @options.tipJoint.horizontal
      targetJoint.setHorizontal @options.targetJoint.horizontal if @options.targetJoint
    if newSticksOut[1] and (newSticksOut[1] isnt sticksOut[1])
      revertedY = yes
      tipJoint.setVertical @options.tipJoint.vertical
      targetJoint.setVertical @options.targetJoint.vertical if @options.targetJoint
    return originals if revertedX and revertedY
      
    if revertedX or revertedY | 
| One of the positions have been reverted. So get the position again. |       stem = tipJoint if @options.stem
      position = @getPosition e, tipJoint, targetJoint, stem
    {
      position: position
      stem: stem
    }
  _sticksOut: (position) ->
    scrollOffset = @adapter.scrollOffset()
      
    viewportDimensions = @adapter.viewportDimensions()
   
    positionOffset = [
      position.left - scrollOffset[0]
      position.top - scrollOffset[1]
    ]
    sticksOut = [ no, no ]
    if positionOffset[0] < 0
      sticksOut[0] = @STICKS_OUT_LEFT 
    else if positionOffset[0] + @dimensions.width > viewportDimensions.width
      sticksOut[0] = @STICKS_OUT_RIGHT
    if positionOffset[1] < 0
      sticksOut[1] = @STICKS_OUT_TOP 
    else if positionOffset[1] + @dimensions.height > viewportDimensions.height
      sticksOut[1] = @STICKS_OUT_BOTTOM 
    sticksOut | 
| This is by far the most complex and difficult function to understand. I tried to comment everything as good as possible |   _draw: -> | 
| This function could be called before _buildElements() |     return unless @backgroundCanvas and @redraw
    @debug "Drawing background."
    @redraw = off | 
| Prepare for the close button |     closeButtonInner = [ 0, 0 ]
    closeButtonOuter = [ 0, 0 ]
    if "closeButton" in @options.hideTriggers
      closeButton = new Opentip.Joint(if @currentStem?.toString() == "top right" then "top left" else "top right")
      closeButtonInner = [
        @options.closeButtonRadius + @options.closeButtonOffset[0]
        @options.closeButtonRadius + @options.closeButtonOffset[1]
      ]
      closeButtonOuter = [
        @options.closeButtonRadius - @options.closeButtonOffset[0]
        @options.closeButtonRadius - @options.closeButtonOffset[1]
      ] | 
| Now for the canvas dimensions and position |     canvasDimensions = @adapter.clone @dimensions
    canvasPosition = [ 0, 0 ] | 
| Account for border |     if @options.borderWidth
      canvasDimensions.width += @options.borderWidth * 2
      canvasDimensions.height += @options.borderWidth * 2
      canvasPosition[0] -= @options.borderWidth
      canvasPosition[1] -= @options.borderWidth | 
| Account for the shadow |     if @options.shadow
      canvasDimensions.width += @options.shadowBlur * 2 | 
| If the shadow offset is bigger than the actual shadow blur, the whole canvas gets bigger |       canvasDimensions.width += Math.max 0, @options.shadowOffset[0] - @options.shadowBlur * 2
      
      canvasDimensions.height += @options.shadowBlur * 2
      canvasDimensions.height += Math.max 0, @options.shadowOffset[1] - @options.shadowBlur * 2
      canvasPosition[0] -= Math.max 0, @options.shadowBlur - @options.shadowOffset[0]
      canvasPosition[1] -= Math.max 0, @options.shadowBlur - @options.shadowOffset[1] | 
|  |  | 
| Bulges could be caused by stems or close buttons |     bulge = left: 0, right: 0, top: 0, bottom: 0 | 
| Account for the stem |     if @currentStem
      if @currentStem.left then bulge.left = @options.stemLength
      else if @currentStem.right then bulge.right = @options.stemLength
      if @currentStem.top then bulge.top = @options.stemLength
      else if @currentStem.bottom then bulge.bottom = @options.stemLength | 
| Account for the close button |     if closeButton
      if closeButton.left then bulge.left = Math.max bulge.left, closeButtonOuter[0]
      else if closeButton.right then bulge.right = Math.max bulge.right, closeButtonOuter[0]
      if closeButton.top then bulge.top = Math.max bulge.top, closeButtonOuter[1]
      else if closeButton.bottom then bulge.bottom = Math.max bulge.bottom, closeButtonOuter[1]
    canvasDimensions.width += bulge.left + bulge.right
    canvasDimensions.height += bulge.top + bulge.bottom
    canvasPosition[0] -= bulge.left
    canvasPosition[1] -= bulge.top
    if @currentStem and @options.borderWidth
      {stemLength, stemBase} = @_getPathStemMeasures @options.stemBase, @options.stemLength, @options.borderWidth | 
| Need to draw on the DOM canvas element itself |     backgroundCanvas = @adapter.unwrap @backgroundCanvas
    backgroundCanvas.width = canvasDimensions.width
    backgroundCanvas.height = canvasDimensions.height
    @adapter.css @backgroundCanvas,
      width: "#{backgroundCanvas.width}px"
      height: "#{backgroundCanvas.height}px"
      left: "#{canvasPosition[0]}px"
      top: "#{canvasPosition[1]}px"
    ctx = backgroundCanvas.getContext "2d"
    ctx.clearRect 0, 0, backgroundCanvas.width, backgroundCanvas.height
    ctx.beginPath()
    ctx.fillStyle = @_getColor ctx, @dimensions, @options.background, @options.backgroundGradientHorizontal
    ctx.lineJoin = "miter"
    ctx.miterLimit = 500 | 
| Since borders are always in the middle and I want them outside I need to draw the actual path half the border width outset. (hb = half border) |     hb = @options.borderWidth / 2
    if @options.borderWidth
      ctx.strokeStyle = @options.borderColor
      ctx.lineWidth = @options.borderWidth
    else
      stemLength = @options.stemLength
      stemBase = @options.stemBase | 
| Draws a line with stem if necessary |     drawLine = (length, stem, first) =>
      if first | 
| This ensures that the outline is properly closed |         ctx.moveTo Math.max(stemBase, @options.borderRadius, closeButtonInner[0]) + 1 - hb, -hb
      if stem
        ctx.lineTo length / 2 - stemBase / 2, -hb
        ctx.lineTo length / 2, - stemLength - hb
        ctx.lineTo length / 2 + stemBase / 2, -hb | 
| Draws a corner with stem if necessary |     drawCorner = (stem, closeButton, i) =>
      if stem
        ctx.lineTo -stemBase + hb, 0 - hb
        ctx.lineTo stemLength + hb, -stemLength - hb
        ctx.lineTo hb, stemBase - hb
      else if closeButton
        offset = @options.closeButtonOffset
        innerWidth = closeButtonInner[0]
        if i % 2 != 0 | 
| Since the canvas gets rotated for every corner, but the close button is always defined as [ horizontal, vertical ] offsets, I have to switch the offsets in case the canvas is rotated by 90degs |           offset = [ offset[1], offset[0] ]
          innerWidth = closeButtonInner[1] | 
| Basic math I added a graphical explanation since it's sometimes hard to understand geometrical calculations without visualization: https://raw.github.com/enyo/opentip/develop/files/close-button-angle.png |         angle1 = Math.acos(offset[1] / @options.closeButtonRadius)
        angle2 = Math.acos(offset[0] / @options.closeButtonRadius)
        ctx.lineTo -innerWidth + hb, -hb
        ctx.arc hb-offset[0], -hb+offset[1], @options.closeButtonRadius, -(Math.PI / 2 + angle1), angle2
      else
        ctx.lineTo -@options.borderRadius + hb, -hb
        ctx.quadraticCurveTo hb, -hb, hb, @options.borderRadius - hb | 
| Start drawing without caring about the shadows or stems The canvas position is exactly the amount that has been moved to account for shadows and stems |     ctx.translate -canvasPosition[0], -canvasPosition[1]
    ctx.save()
    do => # Wrapping variables | 
| This part is a bit funky... All in all I just iterate over all four corners, translate the canvas to it and rotate it so the next line goes to the right. This way I can call drawLine and drawCorner withouth them knowing which line their actually currently drawing. |       for i in [0...Opentip.positions.length/2]
        positionIdx = i * 2
        positionX = if i == 0 or i == 3 then 0 else @dimensions.width
        positionY = if i < 2 then 0 else @dimensions.height
        rotation = (Math.PI / 2) * i
        lineLength = if i % 2 == 0 then @dimensions.width else @dimensions.height
        lineStem = new Opentip.Joint Opentip.positions[positionIdx]
        cornerStem = new Opentip.Joint Opentip.positions[positionIdx + 1]
        ctx.save()
        ctx.translate positionX, positionY
        ctx.rotate rotation
        drawLine lineLength, lineStem.eql(@currentStem), i == 0
        ctx.translate lineLength, 0
        drawCorner cornerStem.eql(@currentStem), cornerStem.eql(closeButton), i
        ctx.restore()
    ctx.closePath()
    ctx.save()
    if @options.shadow
      ctx.shadowColor = @options.shadowColor
      ctx.shadowBlur = @options.shadowBlur
      ctx.shadowOffsetX = @options.shadowOffset[0]
      ctx.shadowOffsetY = @options.shadowOffset[1]
    ctx.fill()
    ctx.restore() # Without shadow
    ctx.stroke() if @options.borderWidth
    ctx.restore() # Without shadow
    if closeButton
      do => | 
| Draw the cross |         crossWidth = crossHeight = @options.closeButtonRadius * 2
        if closeButton.toString() == "top right"
          linkCenter = [
            @dimensions.width - @options.closeButtonOffset[0]
            @options.closeButtonOffset[1]
          ]
          crossCenter = [
            linkCenter[0] + hb
            linkCenter[1] - hb
          ]
        else
          linkCenter = [
            @options.closeButtonOffset[0]
            @options.closeButtonOffset[1]
          ]
          crossCenter = [
            linkCenter[0] - hb
            linkCenter[1] - hb
          ]
        ctx.translate crossCenter[0], crossCenter[1]
        hcs = @options.closeButtonCrossSize / 2
        ctx.save()
        ctx.beginPath()
        ctx.strokeStyle = @options.closeButtonCrossColor
        ctx.lineWidth = @options.closeButtonCrossLineWidth
        ctx.lineCap = "round"
        ctx.moveTo -hcs, -hcs
        ctx.lineTo hcs, hcs
        ctx.stroke()
        ctx.beginPath()
        ctx.moveTo hcs, -hcs
        ctx.lineTo -hcs, hcs
        ctx.stroke()
        ctx.restore() | 
| Position the link |         
        @adapter.css @closeButtonElement,
          left: "#{linkCenter[0] - hcs - @options.closeButtonLinkOverscan}px"
          top: "#{linkCenter[1] - hcs - @options.closeButtonLinkOverscan}px"
          width: "#{@options.closeButtonCrossSize + @options.closeButtonLinkOverscan * 2}px"
          height: "#{@options.closeButtonCrossSize + @options.closeButtonLinkOverscan * 2}px" | 
| I have to account for the border width when implementing the stems. The tip height & width obviously should be added to the outer border, but the path is drawn in the middle of the border. If I just draw the stem size specified on the path, the stem will be bigger than requested. So I have to calculate the stemBase and stemLength of the path stem. |   _getPathStemMeasures: (outerStemBase, outerStemLength, borderWidth) -> | 
| Now for some math! / | angle / | \ / | \ /_|_\ |     hb = borderWidth / 2 | 
| This is the angle of the tip |     halfAngle = Math.atan (outerStemBase / 2) / outerStemLength
    angle = halfAngle * 2 | 
| The rhombus from the border tip to the path tip |     rhombusSide = hb / Math.sin angle
    distanceBetweenTips = 2 * rhombusSide * Math.cos halfAngle
    stemLength = hb + outerStemLength - distanceBetweenTips
    throw new Error "Sorry but your stemLength / stemBase ratio is strange." if stemLength < 0 | 
| Now calculate the new base |     stemBase = (Math.tan(halfAngle) * stemLength) * 2
    { stemLength: stemLength, stemBase: stemBase } | 
| Turns a color string into a possible gradient |   _getColor: (ctx, dimensions, color, horizontal = no) -> | 
| There is no comma so just return |     return color if typeof color == "string" | 
| Create gradient |     if horizontal
      gradient = ctx.createLinearGradient 0, 0, dimensions.width, 0
    else
      gradient = ctx.createLinearGradient 0, 0, 0, dimensions.height
    for colorStop, i in color
      gradient.addColorStop colorStop[0], colorStop[1]
    gradient
  _searchAndActivateCloseButtons: ->
    for element in @adapter.findAll @container, ".#{@class.close}"
      @hideTriggers.push
        element: @adapter.wrap element
        event: "click" | 
| Creating the observers for the new close buttons |     @_setupObservers "-showing", "showing" if @currentObservers.showing
    @_setupObservers "-visible", "visible" if @currentObservers.visible
  _activateFirstInput: ->
    input = @adapter.unwrap @adapter.find @container, "input, textarea"
    input?.focus?() | 
| Calls reposition() everytime the mouse moves |   _followMousePosition: -> @adapter.observe document.body, "mousemove", @bound.reposition unless @options.fixed | 
| Removes observer |   _stopFollowingMousePosition: -> @adapter.stopObserving document.body, "mousemove", @bound.reposition unless @options.fixed | 
| I thinks those are self explanatory |   _clearShowTimeout: -> clearTimeout @_showTimeoutId
  _clearHideTimeout: -> clearTimeout @_hideTimeoutId
  _clearTimeouts: ->
    clearTimeout @_visibilityStateTimeoutId
    @_clearShowTimeout()
    @_clearHideTimeout() | 
| Makes sure the trigger element exists, is visible, and part of this world. |   _triggerElementExists: ->
    el = @adapter.unwrap @triggerElement
    while el.parentNode
      return yes if el.parentNode.tagName == "BODY"
      el = el.parentNode | 
| TODO: Add a check if the element is actually visible |     return no
  _loadAjax: ->
    return if @loading
    @loaded = no
    @loading = yes
    @adapter.addClass @container, @class.loading
    @debug "Loading content from #{@options.ajax}"
    @adapter.ajax
      url: @options.ajax
      method: @options.ajaxMethod
      onSuccess: (responseText) =>
        @debug "Loading successful." | 
| This has to happen before setting the content since loading indicators may still be visible. |         @adapter.removeClass @container, @class.loading
        @setContent responseText
      onError: (error) =>
        message = "There was a problem downloading the content."
        @debug message, error
        @setContent message
        @adapter.addClass @container, @class.ajaxError
      onComplete: =>
        @adapter.removeClass @container, @class.loading
        @loading = no
        @loaded = yes
        @_searchAndActivateCloseButtons()
        @_activateFirstInput()
        @reposition() | 
| Regularely checks if the element is still in the dom. |   _ensureTriggerElement: ->
    unless @_triggerElementExists()
      @deactivate()
      @_stopEnsureTriggerElement() | 
| In milliseconds, how often opentip should check for the existance of the element |   _ensureTriggerElementInterval: 1000 | 
| Sets up an interval to call _ensureTriggerElement regularely |   _startEnsureTriggerElement: ->
    @_ensureTriggerElementTimeoutId = setInterval (=> @_ensureTriggerElement()), @_ensureTriggerElementInterval | 
| Stops the interval |   _stopEnsureTriggerElement: ->
    clearInterval @_ensureTriggerElementTimeoutId | 
| Utils | vendors = [
  "khtml"
  "ms"
  "o"
  "moz"
  "webkit"
] | 
| Sets a sepcific css3 value for all vendors | Opentip::setCss3Style = (element, styles) ->
  element = @adapter.unwrap element
  for own prop, value of styles
    if element.style[prop]?
      element.style[prop] = value
    else
      for vendor in vendors
        vendorProp = "#{@ucfirst vendor}#{@ucfirst prop}"
        element.style[vendorProp] = value if element.style[vendorProp]? | 
| Defers the call | Opentip::defer = (func) -> setTimeout func, 0 | 
| Changes seconds to milliseconds | Opentip::setTimeout = (func, seconds) -> setTimeout func, if seconds then seconds * 1000 else 0 | 
| Turns only the first character uppercase | Opentip::ucfirst = (string) ->
  return "" unless string?
  string.charAt(0).toUpperCase() + string.slice(1) | 
| Converts a camelized string into a dasherized one | Opentip::dasherize = (string) ->
  string.replace /([A-Z])/g, (_, char) -> "-#{char.toLowerCase()}" | 
| Every position is converted to this class | class Opentip.Joint | 
| Accepts pointer in nearly every form. 
 All that counts is that the words top, bottom, left or right are present. It also accepts a Pointer object, creating a new object then |   constructor: (pointerString) ->
    return unless pointerString?
    if pointerString instanceof Opentip.Joint
      pointerString = pointerString.toString()
    @set pointerString
    @
  set: (string) ->
    string = string.toLowerCase()
    @setHorizontal string
    @setVertical string
    @
  setHorizontal: (string) ->
    valid = [ "left", "center", "right" ]
    @horizontal = i.toLowerCase() for i in valid when ~string.indexOf i
    @horizontal = "center" unless @horizontal?
    for i in valid
      this[i] = if @horizontal == i then i else undefined
  setVertical: (string) ->
    valid = [ "top", "middle", "bottom" ]
    @vertical = i.toLowerCase() for i in valid when ~string.indexOf i
    @vertical = "middle" unless @vertical?
    for i in valid
      this[i] = if @vertical == i then i else undefined | 
| Checks if two pointers point in the same direction |   eql: (pointer) ->
    pointer? and @horizontal == pointer.horizontal and @vertical == pointer.vertical | 
| Turns topLeft into bottomRight |   flip: ->
    positionIdx = Opentip.position[@toString yes] | 
| There are 8 positions, and smart as I am I layed them out in a circle. |     flippedIndex = (positionIdx + 4) % 8
    @set Opentip.positions[flippedIndex]
    @
  toString: (camelized = no) ->
    vertical = if @vertical == "middle" then "" else @vertical
    horizontal = if @horizontal == "center" then "" else @horizontal
    if vertical and horizontal
      if camelized then horizontal = Opentip::ucfirst horizontal
      else horizontal = " #{horizontal}"
    "#{vertical}#{horizontal}" | 
| Returns true if top and left are equal | Opentip::_positionsEqual = (posA, posB) ->
  posA? and posB? and posA.left == posB.left and posA.top == posB.top | 
| Returns true if width and height are equal | Opentip::_dimensionsEqual = (dimA, dimB) ->
  dimA? and dimB? and dimA.width == dimB.width and dimA.height == dimB.height | 
| Just forwards to console.debug if Opentip.debug is true and console.debug exists. | Opentip::debug = (args...) ->
  if Opentip.debug and console?.debug?
    args.unshift "##{@id} |"
    console.debug args...  | 
| Startup | Opentip.findElements = ->
  adapter = Opentip.adapter | 
| Go through all elements with  |   for element in adapter.findAll document.body, "[data-ot]"
    options = { }
    content = adapter.data element, "ot"
    if content in [ "", "true", "yes"] | 
| Take the content from the title attribute |       content = adapter.attr element, "title"
      adapter.attr element, "title", ""
    content = content || ""
    for optionName of Opentip.styles.standard
      if optionValue = adapter.data element, "ot#{Opentip::ucfirst optionName}"
        if optionValue in [ "yes", "true", "on" ] then optionValue = true 
        else if optionValue in [ "no", "false", "off" ] then optionValue = false
        options[optionName] = optionValue
    new Opentip element, content, options | 
| Publicly available | Opentip.version = "2.0.0-dev"
Opentip.debug = off
Opentip.lastId = 0
Opentip.lastZIndex = 100
Opentip.tips = [ ]
Opentip._abortShowingGroup = -> | 
| TODO | Opentip._hideGroup = -> | 
| TODO |  | 
| A list of possible adapters. Used for testing | Opentip.adapters = { } | 
| The current adapter used. | Opentip.adapter = null
firstAdapter = yes
Opentip.addAdapter = (adapter) ->
  Opentip.adapters[adapter.name] = adapter
  if firstAdapter
    Opentip.adapter = adapter
    adapter.domReady Opentip.findElements
    firstAdapter = no
Opentip.positions = [
  "top"
  "topRight"
  "right"
  "bottomRight"
  "bottom"
  "bottomLeft"
  "left"
  "topLeft"
]
Opentip.position = { }
for position, i in Opentip.positions
  Opentip.position[position] = i | 
| The standard style. | Opentip.styles =
  standard:     | 
| This style also contains all default values for other styles. Following abbreviations are used: 
 |  | 
| Will be set if provided in constructor |     title: undefined | 
| Whether the provided title should be html escaped |     escapeTitle: yes | 
| Whether the content should be html escaped |     escapeContent: no | 
| The class name to be added to the HTML element |     className: "standard" | 
| 
 |     stem: yes | 
| 
 |     delay: null | 
| See delay |     hideDelay: 0.1 | 
| If target is not null, elements are always fixed. |     fixed: no | 
| 
 |     showOn: "mouseover" | 
| 
 This is just a shortcut, and will be added to hideTriggers |     hideTrigger: "trigger" | 
| An array of hideTriggers. |     hideTriggers: [ ] | 
| 
 |     hideOn: null | 
| 
 |     offset: [ 0, 0 ] | 
| Whether the targetJoint/tipJoint should be changed if the tooltip is not in the viewport anymore. |     containInViewport: true | 
| If set to true, offsets are calculated automatically to position the tooltip. (pixels are added if there are stems for example) |     autoOffset: true
    showEffect: "appear"
    hideEffect: "fade"
    showEffectDuration: 0.3
    hideEffectDuration: 0.2 | 
| integer |     stemLength: 5 | 
| integer |     stemBase: 8 | 
| 
 |     tipJoint: "top left" | 
| 
 |     target: null  | 
| 
 |     targetJoint: null  | 
| AJAX URL
Set to  |     ajax: off | 
| Which method should AJAX use. |     ajaxMethod: "GET" | 
| If off, the content will be downloaded every time the tooltip is shown. |     ajaxCache: on | 
| You can group opentips together. So when a tooltip shows, it looks if there are others in the same group, and hides them. |     group: null | 
| Will be set automatically in constructor |     style: null | 
| The background color of the tip |     background: "#fff18f" | 
| Whether the gradient should be horizontal. |     backgroundGradientHorizontal: no | 
| Positive values offset inside the tooltip |     closeButtonOffset: [ 5, 5 ] | 
| The little circle that stick out of a tip |     closeButtonRadius: 7 | 
| Size of the cross |     closeButtonCrossSize: 4 | 
| Color of the cross |     closeButtonCrossColor: "#d2c35b" | 
| The stroke width of the cross |     closeButtonCrossLineWidth: 1.5 | 
| You will most probably never want to change this. It specifies how many pixels the invisible element should be larger than the actual cross |     closeButtonLinkOverscan: 6 | 
| Border radius... |     borderRadius: 5 | 
| Set to 0 or false if you don't want a border |     borderWidth: 1 | 
| Normal CSS value |     borderColor: "#f2e37b" | 
| Set to false if you don't want a shadow |     shadow: yes | 
| How the shadow should be blurred. Set to 0 if you want a hard drop shadow |     shadowBlur: 10 | 
| Shadow offset... |     shadowOffset: [ 3, 3 ] | 
| Shadow color... |     shadowColor: "rgba(0, 0, 0, 0.1)"
  slick:
    className: "slick"
    stem: true
  rounded:
    className: "rounded"
    stem: true
  glass:
    className: "glass"
  dark:
    className: "dark"
    borderRadius: 13
    borderColor: "#444"
    closeButtonCrossColor: "rgba(240, 240, 240, 1)"
    shadowColor: "rgba(0, 0, 0, 0.3)"
    shadowOffset: [ 2, 2 ]
    background: [
      [ 0, "rgba(30, 30, 30, 0.7)" ]
      [ 0.5, "rgba(30, 30, 30, 0.8)" ]
      [ 0.5, "rgba(10, 10, 10, 0.8)" ]
      [ 1, "rgba(10, 10, 10, 0.9)" ]
    ] | 
| Change this to the style name you want all your tooltips to have as default. | Opentip.defaultStyle = "standard"
window.Opentip = Opentip
 |