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 @html(' ') if $.browser.mozilla && @html() == '' # 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 = $.browser.mozilla && @element.get(0).tagName != 'DIV' # make it editable # gecko: in 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 $(element).attr('data-version', '1') # add the basic editor settings to the document (only once) unless @document.mercuryEditing @document.execCommand('styleWithCSS', false, false) @document.execCommand('insertBROnReturn', false, true) @document.execCommand('enableInlineTableEditing', false, false) @document.execCommand('enableObjectResizing', false, false) @document.mercuryEditing = true bindEvents: -> super Mercury.bind 'region:update', => return if @previewing return unless Mercury.region == @ setTimeout((=> @selection().forceSelection(@element.get(0))), 1) currentElement = @currentElement() if currentElement.length # setup the table editor if we're inside a table table = currentElement.closest('table', @element) Mercury.tableEditor(table, currentElement) 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, "#{anchor.attr('href')}", {position: 'below'}) else Mercury.tooltip.hide() @element.bind 'dragenter', (event) => return if @previewing event.preventDefault() if event.shiftKey event.originalEvent.dataTransfer.dropEffect = 'copy' @element.bind 'dragover', (event) => return if @previewing event.preventDefault() if event.shiftKey event.originalEvent.dataTransfer.dropEffect = 'copy' if $.browser.webkit clearTimeout(@dropTimeout) @dropTimeout = setTimeout((=> @element.trigger('possible:drop')), 10) @element.bind 'drop', (event) => return if @previewing # handle dropping snippets clearTimeout(@dropTimeout) @dropTimeout = setTimeout((=> @element.trigger('possible:drop')), 1) # 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/the_html5_drag.html @element.bind 'possible:drop', (event) => return if @previewing if snippet = @element.find('img[data-snippet]').get(0) @focus() Mercury.Snippet.displayOptionsFor($(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 running a quick diff, removing it by calling undo, making our adjustments, and then putting the # content back. This is possible, so it doesn't make sense why it wouldn't be exposed in a sensible way. *sigh* @element.bind 'paste', => return if @previewing return unless Mercury.region == @ Mercury.changes = true html = @html() event.preventDefault() if @specialContainer setTimeout((=> @handlePaste(html)), 1) @element.focus => return if @previewing Mercury.region = @ setTimeout((=> @selection().forceSelection(@element.get(0))), 1) Mercury.trigger('region:focused', {region: @}) @element.blur => return if @previewing Mercury.trigger('region:blurred', {region: @}) Mercury.tooltip.hide() @element.click (event) => $(event.target).closest('a').attr('target', '_top') if @previewing @element.dblclick (event) => return if @previewing image = $(event.target).closest('img', @element) if image.length @selection().selectNode(image.get(0), true) Mercury.trigger('button', {action: 'insertmedia'}) @element.mouseup => return if @previewing @pushHistory() Mercury.trigger('region:update', {region: @}) @element.keydown (event) => return if @previewing Mercury.changes = true 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 $.browser.webkit && @selection().commonAncestor().closest('li, ul', @element).length == 0 event.preventDefault() @document.execCommand('insertlinebreak', false, null) else if @specialContainer # mozilla: pressing enter in any elemeny besides a div handles strangely event.preventDefault() @document.execCommand('insertHTML', false, '
') when 9 # tab event.preventDefault() container = @selection().commonAncestor() handled = false # indent when inside of an li if container.closest('li', @element).length handled = true if event.shiftKey then @execCommand('outdent') else @execCommand('indent') @execCommand('insertHTML', {value: '  '}) unless handled 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.keyup => return if @previewing Mercury.trigger('region:update', {region: @}) focus: -> @element.focus() setTimeout((=> @selection().forceSelection(@element.get(0))), 1) Mercury.trigger('region:update', {region: @}) html: (value = null, filterSnippets = true, includeMarker = false) -> if value != null # sanitize the html before we insert it container = $('
').appendTo(@document.createDocumentFragment()) container.html(value) # fill in the snippet contents for element in container.find('[data-snippet]') element.contentEditable = false element = $(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 = $('
').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 = $(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 html = container.html() # remove the markers from the dom selection.removeMarker() if includeMarker return html togglePreview: -> if @previewing @element.get(0).contentEditable = true @element.css({overflow: 'auto'}) else @html(@html()) @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 = $('
').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 @element.prev().remove() if action == 'indent' && @element.prev() != sibling 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(@html(null, false, true)) else if keyCode # set a timeout for pushing to the history @historyTimeout = setTimeout((=> @history.push(@html(null, false, true))), waitTime * 1000) else # push to the history immediately @history.push(@html(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: (prePasteHTML) -> prePasteHTML = prePasteHTML.replace(/^\/, '') # remove any regions that might have been pasted @element.find('.mercury-region').remove() # handle pasting from ms office etc html = @html() if html.indexOf('') > -1 || html.indexOf('="mso-') > -1 || html.indexOf(' -1 || html.indexOf('="Mso') > -1 # clean out all the tags from the pasted contents cleaned = prePasteHTML.singleDiff(@html()).sanitizeHTML() try # try to undo and put the cleaned html where the selection was @document.execCommand('undo', false, null) @execCommand('insertHTML', {value: cleaned}) catch error # remove the pasted html and load up the cleaned contents into a modal @html(prePasteHTML) Mercury.modal '/mercury/modals/sanitizer', { title: 'HTML Sanitizer (Starring Clippy)', afterLoad: -> @element.find('textarea').val(cleaned.replace(//g, '\n')) } else if Mercury.config.cleanStylesOnPaste # strip styles pasted = prePasteHTML.singleDiff(@html()) container = $('
').appendTo(@document.createDocumentFragment()).html(pasted) container.find('[style]').attr({style: null}) @document.execCommand('undo', false, null) @execCommand('insertHTML', {value: container.html()}) # 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: -> @html(@history.undo()) redo: -> @html(@history.redo()) removeformatting: (selection) -> selection.insertTextNode(selection.textContent()) backcolor: (selection, options) -> selection.wrap("", true) overline: (selection) -> selection.wrap('', true) style: (selection, options) -> selection.wrap("", true) replaceHTML: (selection, options) -> @html(options.value) insertImage: (selection, options) -> @execCommand('insertHTML', {value: $('', options.value)}) insertLink: (selection, options) -> anchor = $("<#{options.value.tagName}>").attr(options.value.attrs).html(options.value.content) selection.insertNode(anchor) replaceLink: (selection, options) -> anchor = $("<#{options.value.tagName}>").attr(options.value.attrs).html(options.value.content) selection.selectNode(options.node) html = $('
').html(selection.content()).find('a').html() selection.replace($(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() commonAncestor: (onlyTag = false) -> return null unless @range ancestor = @range.commonAncestorContainer ancestor = ancestor.parentNode if ancestor.nodeType == 3 && onlyTag return $(ancestor) wrap: (element, replace = false) -> element = $(element, @context).html(@fragment) @replace(element) if replace return element textContent: -> return @range.cloneContents().textContent content: -> return @range.cloneContents() is: (elementType) -> content = @content() return $(content.firstChild) if content.childNodes.length == 1 && $(content.firstChild).is(elementType) return false forceSelection: (element) -> return unless $.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 = $('', @context).get(0) @endMarker = $('', @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: -> $(@startMarker).remove() $(@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 = $(element, @context).get(0) if $.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) -> element = element.get(0) if element.get element = $(element, @context).get(0) if $.type(element) == 'string' @range.deleteContents() @range.insertNode(element) @range.selectNodeContents(element) @selection.addRange(@range)