Jump To …
adapter.ender.coffee adapter.jquery.coffee adapter.native.coffee adapter.prototype.coffee opentip.coffee

# opentip.coffee

¶

# #

More info at www.opentip.org

Copyright (c) 2012, Matias Meno
Graphics by Tjandra Mayerhold

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.

# #

¶

## Opentip

Usage:

    <div data-ot="This will be viewed in tooltip"></div>

or externally:

    new Opentip(element, content, title, options);

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.

`content`, `title` and `options` are optional but have to be in this order.

      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 `_updateElementContent()`

        @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.

- "top left"
- "topLeft"
- "top-left"
- "RIGHT to TOP"

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 `data-ot="[...]"`

      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:

- `POINTER` : a string that contains at least one of top, bottom, right or left
- `OFFSET` : [ XVALUE, YVALUE ] (integers)
- `ELEMENT` : element or element id

¶

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"

¶

- `false` (no stem)
- `true` (stem at tipJoint position)
- `POINTER` (for stems in other directions)

        stem: yes

¶

`float` (in seconds) If null, the default is used: 0.2 for mouseover, 0 for click

        delay: null

¶

See delay

        hideDelay: 0.1

¶

If target is not null, elements are always fixed.

        fixed: no

¶

- eventname (eg: `"click"`, `"mouseover"`, etc..)
- `"creation"` (the tooltip will show when being created)
- `null` if you want to handle it yourself (Opentip will not register for any events)

        showOn: "mouseover"

¶

- `"trigger"`
- `"tip"`
- `"target"`
- `"closeButton"`
- `ELEMENT`

This is just a shortcut, and will be added to hideTriggers

        hideTrigger: "trigger"

¶

An array of hideTriggers.

        hideTriggers: [ ]

¶

- eventname (eg: `"click"`)
- array of event strings if multiple hideTriggers
- `null` (let Opentip decide)

        hideOn: null

¶

`OFFSET`

        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

¶

`POINTER`

        tipJoint: "top left"

¶

- `null` (no target, opentip uses mouse as target)
- `true` (target is the triggerElement)
- `ELEMENT` (for another element)

        target: null

¶

- `POINTER` (Ignored if target == `null`)
- `null` (targetJoint is the opposite of tipJoint)

        targetJoint: null

¶

AJAX URL Set to `false` if no AJAX or `true` if it's attached to an `<a />` element. In the latter case the `href` attribute will be used.

        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