Mercury Editor

editable.js.coffee

class @Mercury.Regions.Editable extends Mercury.Region
  type = 'editable'

  constructor: (@element, @window, @options = {}) ->
    @type = 'editable'
    super


  build: ->

mozilla: set some initial content so everything works correctly

    @content(' ') if jQuery.browser.mozilla && @content() == ''

set overflow just in case

    @element.data({originalOverflow: @element.css('overflow')})
    @element.css({overflow: 'auto'})

mozilla: there’s some weird behavior when the element isn’t a div

    @specialContainer = jQuery.browser.mozilla && @element.get(0).tagName != 'DIV'

make it editable mozilla: this makes double clicking in textareas fail: https://bugzilla.mozilla.org/show_bug.cgi?id=490367

    @element.get(0).contentEditable = true

make all snippets not editable, and set their versions to 1

    for element in @element.find('.mercury-snippet')
      element.contentEditable = false
      jQuery(element).attr('data-version', '1')

add the basic editor settings to the document (only once)

    unless @document.mercuryEditing
      @document.mercuryEditing = true
      try
        @document.execCommand('styleWithCSS', false, false)
        @document.execCommand('insertBROnReturn', false, true)
        @document.execCommand('enableInlineTableEditing', false, false)
        @document.execCommand('enableObjectResizing', false, false)
      catch e

intentionally do nothing if any of these fail, to broaden support for Opera

  bindEvents: ->
    super

    Mercury.on 'region:update', =>
      return if @previewing || Mercury.region != @
      setTimeout(1, => @selection().forceSelection(@element.get(0)))
      currentElement = @currentElement()
      if currentElement.length

setup the table editor if we’re inside a table

        table = currentElement.closest('table', @element)
        Mercury.tableEditor(table, currentElement.closest('tr, td'), '<br/>') if table.length

display a tooltip if we’re in an anchor

        anchor = currentElement.closest('a', @element)
        if anchor.length && anchor.attr('href')
          Mercury.tooltip(anchor, "<a href=\"#{anchor.attr('href')}\" target=\"_blank\">#{anchor.attr('href')}</a>", {position: 'below'})
        else
          Mercury.tooltip.hide()

    @element.on 'dragenter', (event) =>
      return if @previewing
      event.preventDefault() unless Mercury.snippet
      event.originalEvent.dataTransfer.dropEffect = 'copy'

    @element.on 'dragover', (event) =>
      return if @previewing
      event.preventDefault() unless Mercury.snippet
      event.originalEvent.dataTransfer.dropEffect = 'copy'
      if jQuery.browser.webkit
        clearTimeout(@dropTimeout)
        @dropTimeout = setTimeout(10, => @element.trigger('possible:drop'))

    @element.on 'drop', (event) =>
      return if @previewing

handle dropping snippets

      clearTimeout(@dropTimeout)
      @dropTimeout = setTimeout(1, => @element.trigger('possible:drop'))

handle any files that were dropped

      return unless event.originalEvent.dataTransfer.files.length
      event.preventDefault()
      @focus()
      Mercury.uploader(event.originalEvent.dataTransfer.files[0])

possible:drop custom event: we have to do this because webkit doesn’t fire the drop event unless both dragover and dragstart default behaviors are canceled.. but when we do that and observe the drop event, the default behavior isn’t handled (eg, putting the image where it was dropped,) so to allow the browser to do it’s thing, and also do our thing we have this little hack. sigh read: http://www.quirksmode.org/blog/archives/2009/09/thehtml5drag.html

    @element.on 'possible:drop', =>
      return if @previewing
      if snippet = @element.find('img[data-snippet]').get(0)
        @focus()
        Mercury.Snippet.displayOptionsFor(jQuery(snippet).data('snippet'))
        @document.execCommand('undo', false, null)

custom paste handling: we have to do some hackery to get the pasted content since it’s not exposed normally through a clipboard in firefox (heaven forbid), and to keep the behavior across all browsers, we manually detect what was pasted by focusing a different (hidden) region, letting it paste there, making our adjustments, and then inserting the content where it was intended. This is possible, so it doesn’t make sense why it wouldn’t be exposed in a sensible way. sigh

    @element.on 'paste', (event) =>
      return if @previewing || Mercury.region != @
      if @specialContainer
        event.preventDefault()
        return
      return if @pasting
      Mercury.changes = true
      @handlePaste(event.originalEvent)

    @element.on 'focus', =>
      return if @previewing
      Mercury.region = @
      setTimeout(1, => @selection().forceSelection(@element.get(0)))
      Mercury.trigger('region:focused', {region: @})

    @element.on 'blur', =>
      return if @previewing
      Mercury.trigger('region:blurred', {region: @})
      Mercury.tooltip.hide()

    @element.on 'click', (event) =>
      jQuery(event.target).closest('a').attr('target', '_top') if @previewing

    @element.on 'dblclick', (event) =>
      return if @previewing
      image = jQuery(event.target).closest('img', @element)
      if image.length
        @selection().selectNode(image.get(0), true)
        Mercury.trigger('button', {action: 'insertMedia'})

    @element.on 'mouseup', =>
      return if @previewing
      @pushHistory()
      Mercury.trigger('region:update', {region: @})

    @element.on 'keydown', (event) =>
      return if @previewing
      switch event.keyCode
        when 90 # undo / redo
          return unless event.metaKey
          event.preventDefault()
          if event.shiftKey then @execCommand('redo') else @execCommand('undo')
          return

        when 13 # enter
          if jQuery.browser.webkit && @selection().commonAncestor().closest('li, ul, ol', @element).length == 0
            event.preventDefault()
            @document.execCommand('insertParagraph', false, null)
          else if @specialContainer || jQuery.browser.opera

mozilla: pressing enter in any element besides a div handles strangely

            event.preventDefault()
            @document.execCommand('insertHTML', false, '<br/>')

        when 9 # tab
          event.preventDefault()
          container = @selection().commonAncestor()

indent when inside of an li

          if container.closest('li', @element).length
            unless event.shiftKey
              @execCommand('indent')

do not outdent on last ul/ol parent, or we break out of the list

            else if container.parents('ul, ol').length > 1
              @execCommand('outdent')
          else
            @execCommand('insertHTML', {value: '&nbsp;&nbsp;'})

      if event.metaKey
        switch event.keyCode
          when 66 # b
            @execCommand('bold')
            event.preventDefault()

          when 73 # i
            @execCommand('italic')
            event.preventDefault()

          when 85 # u
            @execCommand('underline')
            event.preventDefault()

      @pushHistory(event.keyCode)

    @element.on 'keyup', =>
      return if @previewing
      Mercury.trigger('region:update', {region: @})
      Mercury.changes = true


  focus: ->
    if Mercury.region != @
      setTimeout(10, => @element.focus())
      try
        @selection().selection.collapseToStart()
      catch e

intentially do nothing

    else
      setTimeout(10, => @element.focus())

    Mercury.trigger('region:focused', {region: @})
    Mercury.trigger('region:update', {region: @})


  content: (value = null, filterSnippets = true, includeMarker = false) ->
    if value != null

sanitize the html before we insert it

      container = jQuery('<div>').appendTo(@document.createDocumentFragment())
      container.html(value)

fill in the snippet contents

      for element in container.find('[data-snippet]')
        element.contentEditable = false
        element = jQuery(element)
        if snippet = Mercury.Snippet.find(element.data('snippet'))
          unless element.data('version')
            try
              version = parseInt(element.html().match(/\/(\d+)\]/)[1])
              if version
                snippet.setVersion(version)
                element.attr({'data-version': version})
                element.html(snippet.data)
            catch error

set the html

      @element.html(container.html())

create a selection if there’s markers

      @selection().selectMarker(@element)
    else

remove any meta tags

      @element.find('meta').remove()

place markers for the selection

      if includeMarker
        selection = @selection()
        selection.placeMarker()

sanitize the html before we return it

      container = jQuery('<div>').appendTo(@document.createDocumentFragment())
      container.html(@element.html().replace(/^\s+|\s+$/g, ''))

replace snippet contents to be an identifier

      if filterSnippets then for element, index in container.find('[data-snippet]')
        element = jQuery(element)
        if snippet = Mercury.Snippet.find(element.data("snippet"))
          snippet.data = element.html()
        element.html("[#{element.data("snippet")}/#{element.data("version")}]")
        element.attr({contenteditable: null, 'data-version': null})

get the html before removing the markers

      content = container.html()

remove the markers from the dom

      selection.removeMarker() if includeMarker

      return content


  togglePreview: ->
    if @previewing
      @element.get(0).contentEditable = true
      @element.css({overflow: 'auto'})
    else
      @content(@content())
      @element.get(0).contentEditable = false
      @element.css({overflow: @element.data('originalOverflow')})
      @element.blur()
    super


  execCommand: (action, options = {}) ->
    super

use a custom handler if there’s one, otherwise use execCommand

    if handler = Mercury.config.behaviors[action] || Mercury.Regions.Editable.actions[action]
      handler.call(@, @selection(), options)
    else
      sibling = @element.get(0).previousSibling if action == 'indent'
      options.value = jQuery('<div>').html(options.value).html() if action == 'insertHTML' && options.value && options.value.get
      try
        @document.execCommand(action, false, options.value)
      catch error

mozilla: indenting when there’s no br tag handles strangely todo: mozilla: trying to justify the first line of any contentEditable fails

        @element.prev().remove() if action == 'indent' && @element.prev() != sibling

handle any broken images by replacing the source with an alert image

    @element.find('img').one 'error', -> jQuery(@).attr({src: '/assets/mercury/missing-image.png', title: 'Image not found'})


  pushHistory: (keyCode) ->

when pressing return, delete or backspace it should push to the history all other times it should store if there’s a 1 second pause

    keyCodes = [13, 46, 8]
    waitTime = 2.5
    knownKeyCode = keyCodes.indexOf(keyCode) if keyCode

clear any pushes to the history

    clearTimeout(@historyTimeout)

if the key code was return, delete, or backspace store now — unless it was the same as last time

    if knownKeyCode >= 0 && knownKeyCode != @lastKnownKeyCode # || !keyCode
      @history.push(@content(null, false, true))
    else if keyCode

set a timeout for pushing to the history

      @historyTimeout = setTimeout(waitTime * 1000, => @history.push(@content(null, false, true)))
    else

push to the history immediately

      @history.push(@content(null, false, true))

    @lastKnownKeyCode = knownKeyCode


  selection: ->
    return new Mercury.Regions.Editable.Selection(@window.getSelection(), @document)


  path: ->
    container = @selection().commonAncestor()
    return [] unless container
    return if container.get(0) == @element.get(0) then [] else container.parentsUntil(@element)


  currentElement: ->
    element = []
    selection = @selection()
    if selection.range
      element = selection.commonAncestor()
      element = element.parent() if element.get(0).nodeType == 3
    return element


  handlePaste: (event) ->

get the text content from the clipboard and fall back to using the sanitizer if unavailable

    if Mercury.config.pasting.sanitize == 'text' && event.clipboardData
      @execCommand('insertHTML', {value: event.clipboardData.getData('text/plain')})
      event.preventDefault()
      return
    else

get current selection & range

      selection = @selection()
      selection.placeMarker()

      sanitizer = jQuery('#mercury_sanitizer', @document).focus()

set 1ms timeout to allow paste event to complete

      setTimeout 1, =>

sanitize the content

        content = @sanitize(sanitizer)

move cursor back to original element & position

        selection.selectMarker(@element)
        selection.removeMarker()

paste sanitized content

        @element.focus()
        @execCommand('insertHTML', {value: content})


  sanitize: (sanitizer) ->

always remove nested regions

    sanitizer.find(".#{Mercury.config.regionClass}").remove()

    if Mercury.config.pasting.sanitize
      switch Mercury.config.pasting.sanitize
        when 'blacklist'

todo: finish writing black list functionality

          sanitizer.find('[style]').removeAttr('style')
          sanitizer.find('[class="Apple-style-span"]').removeClass('Apple-style-span')
          content = sanitizer.html()
        when 'whitelist'
          for element in sanitizer.find('*')
            allowed = false
            for allowedTag, allowedAttributes of Mercury.config.pasting.whitelist
              if element.tagName.toLowerCase() == allowedTag.toLowerCase()
                allowed = true
                for attr in jQuery(element.attributes)
                  jQuery(element).removeAttr(attr.name) unless attr.name in allowedAttributes
                break
            jQuery(element).replaceWith(jQuery(element).contents()) unless allowed
          content = sanitizer.html()
        else content = sanitizer.text()
    else

force text if it looks like it’s from word/pages, even if there’s no sanitizing requested

      content = sanitizer.html()
      if content.indexOf('<!--StartFragment-->') > -1 || content.indexOf('="mso-') > -1 || content.indexOf('<o:') > -1 || content.indexOf('="Mso') > -1
        content = sanitizer.text()

    sanitizer.html('')
    return content

Custom actions (eg. things that execCommand doesn’t do, or doesn’t do well)

  @actions: {

    insertRowBefore: -> Mercury.tableEditor.addRow('before')

    insertRowAfter: -> Mercury.tableEditor.addRow('after')

    insertColumnBefore: -> Mercury.tableEditor.addColumn('before')

    insertColumnAfter: -> Mercury.tableEditor.addColumn('after')

    deleteColumn: -> Mercury.tableEditor.removeColumn()

    deleteRow: -> Mercury.tableEditor.removeRow()

    increaseColspan: -> Mercury.tableEditor.increaseColspan()

    decreaseColspan: -> Mercury.tableEditor.decreaseColspan()

    increaseRowspan: -> Mercury.tableEditor.increaseRowspan()

    decreaseRowspan: -> Mercury.tableEditor.decreaseRowspan()

    undo: -> @content(@history.undo())

    redo: -> @content(@history.redo())

    horizontalRule: -> this.execCommand('insertHorizontalRule')

    removeFormatting: (selection) -> selection.insertTextNode(selection.textContent())

    backColor: (selection, options) -> selection.wrap("<span style=\"background-color:#{options.value.toHex()}\">", true)

    overline: (selection) -> selection.wrap('<span style="text-decoration:overline">', true)

    style: (selection, options) -> selection.wrap("<span class=\"#{options.value}\">", true)

    replaceHTML: (selection, options) -> @content(options.value)

    insertImage: (selection, options) -> @execCommand('insertHTML', {value: jQuery('<img/>', options.value)})

    insertTable: (selection, options) -> @execCommand('insertHTML', {value: options.value})

    insertLink: (selection, options) ->
      anchor = jQuery("<#{options.value.tagName}>", @document).attr(options.value.attrs).html(options.value.content)
      selection.insertNode(anchor)

    replaceLink: (selection, options) ->
      anchor = jQuery("<#{options.value.tagName}>", @document).attr(options.value.attrs).html(options.value.content)
      selection.selectNode(options.node)
      html = jQuery('<div>').html(selection.content()).find('a').html()
      selection.replace(jQuery(anchor, selection.context).html(html))

    insertSnippet: (selection, options) ->
      snippet = options.value
      if (existing = @element.find("[data-snippet=#{snippet.identity}]")).length
        selection.selectNode(existing.get(0))
      selection.insertNode(snippet.getHTML(@document))

    editSnippet: ->
      return unless @snippet
      snippet = Mercury.Snippet.find(@snippet.data('snippet'))
      snippet.displayOptions()

    removeSnippet: ->
      @snippet.remove() if @snippet
      Mercury.trigger('hide:toolbar', {type: 'snippet', immediately: true})
  }

Helper class for managing selection and getting information from it

class Mercury.Regions.Editable.Selection

  constructor: (@selection, @context) ->
    return unless @selection.rangeCount >= 1
    @range = @selection.getRangeAt(0)
    @fragment = @range.cloneContents()
    @clone = @range.cloneRange()
    @collapsed = @selection.isCollapsed


  commonAncestor: (onlyTag = false) ->
    return null unless @range
    ancestor = @range.commonAncestorContainer
    ancestor = ancestor.parentNode if ancestor.nodeType == 3 && onlyTag
    return jQuery(ancestor)


  wrap: (element, replace = false) ->
    element = jQuery(element, @context).html(@fragment)
    @replace(element) if replace
    return element


  textContent: ->
    return @range.cloneContents().textContent


  content: ->
    return @range.cloneContents()


  is: (elementType) ->
    content = @content()
    return jQuery(content.firstChild) if jQuery(content).length == 1 && jQuery(content.firstChild).is(elementType)
    return false


  forceSelection: (element) ->
    return unless jQuery.browser.webkit
    range = @context.createRange()

    if @range
      if @commonAncestor(true).closest('.mercury-snippet').length
        lastChild = @context.createTextNode('\00')
        element.appendChild(lastChild)
    else
      if element.lastChild && element.lastChild.nodeType == 3 && element.lastChild.textContent.replace(/^[\s+|\n+]|[\s+|\n+]$/, '') == ''
        lastChild = element.lastChild
        element.lastChild.textContent = '\00'
      else
        lastChild = @context.createTextNode('\00')
        element.appendChild(lastChild)

    if lastChild
      range.setStartBefore(lastChild)
      range.setEndBefore(lastChild)
      @selection.addRange(range)


  selectMarker: (context) ->
    markers = context.find('em.mercury-marker')
    return unless markers.length

    range = @context.createRange()
    range.setStartBefore(markers.get(0))
    range.setEndBefore(markers.get(1)) if markers.length >= 2

    markers.remove()

    @selection.removeAllRanges()
    @selection.addRange(range)


  placeMarker: ->
    return unless @range

    @startMarker = jQuery('<em class="mercury-marker"/>', @context).get(0)
    @endMarker = jQuery('<em class="mercury-marker"/>', @context).get(0)

put a single marker (the end)

    rangeEnd = @range.cloneRange()
    rangeEnd.collapse(false)
    rangeEnd.insertNode(@endMarker)

    unless @range.collapsed

put a start marker

      rangeStart = @range.cloneRange()
      rangeStart.collapse(true)
      rangeStart.insertNode(@startMarker)

    @selection.removeAllRanges()
    @selection.addRange(@range)


  removeMarker: ->
    jQuery(@startMarker).remove()
    jQuery(@endMarker).remove()


  insertTextNode: (string) ->
    node = @context.createTextNode(string)
    @range.extractContents()
    @range.insertNode(node)
    @range.selectNodeContents(node)
    @selection.addRange(@range)


  insertNode: (element) ->
    element = element.get(0) if element.get
    element = jQuery(element, @context).get(0) if jQuery.type(element) == 'string'

    @range.deleteContents()
    @range.insertNode(element)
    @range.selectNodeContents(element)
    @selection.addRange(@range)


  selectNode: (node, removeExisting = false) ->
    @range.selectNode(node)
    @selection.removeAllRanges() if removeExisting
    @selection.addRange(@range)


  replace: (element, collapse) ->
    element = element.get(0) if element.get
    element = jQuery(element, @context).get(0) if jQuery.type(element) == 'string'

    @range.deleteContents()
    @range.insertNode(element)
    @range.selectNodeContents(element)
    @selection.addRange(@range)
    @range.collapse(false) if collapse