JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
replase odd whitespace characters with spaces where allowed
[peach-html5-editor.git] / editor.coffee
index 699134a..dfcf6d2 100644 (file)
@@ -37,6 +37,32 @@ this_url_sans_path = ->
                ret = ret.substr 0, clip + 1
        return ret
 
+# table too look up the properties of various values for css's white-space
+ws_props =
+       normal:
+               space: false            # spaces are not preserved/rendered
+               newline: false          # newlines are not preserved/rendered
+               wrap: true              # text is word-wrapped
+               to_preserve: 'pre-wrap' # to preservespaces, change white-space to this
+       nowrap:
+               space: false
+               newline: false
+               wrap: false
+               to_preserve: 'pre'
+       'pre-line':
+               space: false
+               newline: true
+               wrap: true
+               to_preserve: 'pre-wrap'
+       pre:
+               space: true
+               newline: true
+               wrap: false
+       'pre-wrap':
+               space: true
+               newline: true
+               wrap: true
+
 # xml 1.0 spec, chromium and firefox accept these, plus lots of unicode chars
 valid_attr_regex = new RegExp '^[a-zA-Z_:][-a-zA-Z0-9_:.]*$'
 # html5 spec is much more lax, but chromium won't let me make at attribute with the name "4"
@@ -95,7 +121,13 @@ text_range_bounds = (el, start, end) ->
        range.setEnd el, end
        rects = range.getClientRects()
        if rects.length > 0
-               rect = rects[0]
+               if rects.length > 1
+                       if rects[1].width > rects[0].width
+                               rect = rects[1]
+                       else
+                               rect = rects[0]
+               else
+                       rect = rects[0]
        else
                return null
        doc = el.ownerDocument.documentElement
@@ -540,25 +572,35 @@ tree_dedup_space = (tree) ->
                                if block
                                        cb null
        # remove cur char
-       remove = ->
-               removed_char = cur.text.charAt(cur_i)
-               cur.el.textContent = cur.text = (cur.text.substr 0, cur_i) + (cur.text.substr cur_i + 1)
-               if next is cur # in same text node
-                       if next_i is 0
-                               throw "how is this possible?"
-                       next_i -= 1
-               return true
-       # undo remove()
-       put_it_back = ->
-               cur.el.textContent = cur.text = (cur.text.substr 0, cur_i) + removed_char + (cur.text.substr cur_i)
-               if next is cur # in same text node
-                       next_i += 1
-               return false
+       remove = (undo) ->
+               if undo
+                       cur.el.textContent = cur.text = (cur.text.substr 0, cur_i) + removed_char + (cur.text.substr cur_i)
+                       if next is cur # in same text node
+                               next_i += 1
+                       return -1
+               else
+                       removed_char = cur.text.charAt(cur_i)
+                       cur.el.textContent = cur.text = (cur.text.substr 0, cur_i) + (cur.text.substr cur_i + 1)
+                       if next is cur # in same text node
+                               if next_i is 0
+                                       throw "how is this possible?"
+                               next_i -= 1
+                       return 1
+       whitespace_to_space = (undo) ->
+               if undo
+                       cur.text = (cur.text.substr 0, cur_i) + removed_char + (cur.text.substr cur_i + 1)
+                       cur.el.textContent = cur.text
+               else
+                       removed_char = cur.text.charAt(cur_i)
+                       cur.text = (cur.text.substr 0, cur_i) + ' ' + (cur.text.substr cur_i + 1)
+                       cur.el.textContent = cur.text
+               return 0
        # return true if cur was removed from the dom (ie re-use same prev)
        operate = ->
                # cur definitately set
                # prev and/or next might be null, indicating the start/end of a display:block
                return false unless is_space_code cur.text.charCodeAt cur_i
+               fixers = [remove, whitespace_to_space]
                bounds = text_range_bounds cur.el, cur_i, cur_i + 1
                # consistent cases:
                # 1. zero rects returned by getClientRects() means collapsed space
@@ -566,7 +608,7 @@ tree_dedup_space = (tree) ->
                        return remove()
                # 2. width greater than zero means visible space
                if bounds.w > 0
-                       return false
+                       fixers.shift() # don't try removing
                # now the weird edge cases...
                #
                # firefox and chromium both report zero width for characters at the end
@@ -581,22 +623,38 @@ tree_dedup_space = (tree) ->
                        next_px = new_cursor_position n: next, i: next_i
                #if prev is null and next is null
                #       parent_px = cur.parent.el.getBoundingClientRect()
-               remove()
-               if prev?
-                       if prev_px?
-                               new_prev_px = new_cursor_position n: prev, i: prev_i
-                               if new_prev_px.x isnt prev_px.x or new_prev_px.y isnt prev_px.y
-                                       return put_it_back()
-                       else
-                               console.log "this shouldn't happen, we remove spaces that don't locate"
-               if next?
-                       if next_px?
-                               new_next_px = new_cursor_position n: next, i: next_i
-                               if new_next_px.x isnt next_px.x or new_next_px.y isnt next_px.y
-                                       return put_it_back()
-                       #else
-                       #       console.log "removing space becase space after it is collapsed"
-               return true
+               undo_arg = true # just for readabality
+               removed = 0
+               for fixer in fixers
+                       break if removed > 0
+                       removed += fixer()
+                       need_undo = false
+                       if prev?
+                               if prev_px?
+                                       new_prev_px = new_cursor_position n: prev, i: prev_i
+                                       if new_prev_px?
+                                               if new_prev_px.x isnt prev_px.x or new_prev_px.y isnt prev_px.y
+                                                       need_undo = true
+                                       else
+                                               need_undo = true
+                               else
+                                       console.log "this shouldn't happen, we remove spaces that don't locate"
+                       if next? and not need_undo
+                               if next_px?
+                                       new_next_px = new_cursor_position n: next, i: next_i
+                                       if new_next_px?
+                                               if new_next_px.x isnt next_px.x or new_next_px.y isnt next_px.y
+                                                       need_undo = true
+                                       else
+                                               need_undo = true
+                               #else
+                               #       console.log "removing space becase space after it is collapsed"
+                       if need_undo
+                               removed += fixer undo_arg
+               if removed > 0
+                       return true
+               else
+                       return false
        # pass null at start/end of display:block
        queue = (n, i) ->
                next = n
@@ -850,26 +908,26 @@ class PeachHTML5Editor
                        when KEY_BACKSPACE
                                return false unless @cursor?
                                return false unless @cursor.i > 0
-                               @cursor.n.text = @cursor.n.text.substr(0, @cursor.i - 1) + @cursor.n.text.substr(@cursor.i)
-                               @cursor.n.el.nodeValue = @cursor.n.text
+                               @remove_character @cursor.n, @cursor.i - 1
+                               @adjust_whitespace_style @cursor.n
+                               @changed()
                                new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i - 1
                                if new_cursor?
                                        @move_cursor new_cursor
                                else
                                        @kill_cursor()
-                               @changed()
                                return false
                        when KEY_DELETE
                                return false unless @cursor?
                                return false unless @cursor.i < @cursor.n.text.length
-                               @cursor.n.text = @cursor.n.text.substr(0, @cursor.i) + @cursor.n.text.substr(@cursor.i + 1)
-                               @cursor.n.el.nodeValue = @cursor.n.text
+                               @remove_character @cursor.n, @cursor.i
+                               @adjust_whitespace_style @cursor.n
+                               @changed()
                                new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i
                                if new_cursor?
                                        @move_cursor new_cursor
                                else
                                        @kill_cursor()
-                               @changed()
                                return false
                        when KEY_ENTER
                                return false
@@ -888,32 +946,18 @@ class PeachHTML5Editor
        onkeypress: (e) ->
                return if e.ctrlKey
                return false if ignore_key_codes[e.keyCode]?
-               # return false if control_key_codes[e.keyCode]? # handled in keydown
                char = e.charCode ? e.keyCode
                if char and @cursor?
                        char = String.fromCharCode char
-                       if @cursor.i is 0
-                               @cursor.n.text = char + @cursor.n.text
-                       else if @cursor.i is @cursor.n.text.length - 1
-                               @cursor.n.text += char
-                       else
-                               @cursor.n.text =
-                                       @cursor.n.text.substr(0, @cursor.i) +
-                                       char +
-                                       @cursor.n.text.substr(@cursor.i)
-                       @cursor.n.el.nodeValue = @cursor.n.text
-                       new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i + 1
-                       unless new_cursor
-                               # probably pressed space, and browser isn't displaying it
-                               # FIXME insert &nbsp; instead, rip it out later if possible, etc.
-                               # for now, remove it
-                               @cursor.n.text =
-                                       @cursor.n.text.substr(0, @cursor.i) +
-                                       @cursor.n.text.substr(@cursor.i + 1)
-                               @cursor.n.el.nodeValue = @cursor.n.text
-                               return false
-                       @move_cursor new_cursor
+                       @insert_character @cursor.n, @cursor.i, char
+                       @adjust_whitespace_style @cursor.n
                        @changed()
+                       new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i + 1
+                       if new_cursor
+                               @move_cursor new_cursor
+                       else
+                               console.log "ERROR: couldn't find cursor position after insert"
+                               @kill_cursor()
                return false
        clear_dom: -> # remove all the editable content (and cursor, overlays, etc)
                while @idoc.body.childNodes.length
@@ -940,6 +984,87 @@ class PeachHTML5Editor
                        @iframe.style.height = "0"
                        @iframe.style.height = "#{h}px"
                        @wrap2.scrollTop = s
+       # does this node have whitespace that would be collapsed by white-space: normal?
+       # note: this checks direct text children, and does _not_ recurse into child tags
+       # tag is a node with type:"tag"
+       has_collapsable_space: (tag) ->
+               for n in tag.children
+                       if n.type is 'text'
+                               for i in [0...n.text.length]
+                                       code = n.text.charCodeAt i
+                                       if code isnt 32 and is_space_code code
+                                               # tab, return
+                                               return true
+                                       # check for double spaces that don't surround insert location
+                                       continue if i is 0
+                                       if n.text.substr(i - 1, 2) is '  '
+                                               return true
+                               if n.text.length > 0
+                                       if is_space_code n.text.charCodeAt 0
+                                               return true
+                                       if is_space_code n.text.charCodeAt n.text.length - 1
+                                               return true
+       # add/remove "white-space: pre[-wrap]" to/from style="" on tags with direct
+       # child text nodes with multiple spaces in a row, or spaces at the
+       # start/end.
+       #
+       # text inside child tags are not consulted. Child tags are expected to have
+       # this function applied to them when their content changes.
+       adjust_whitespace_style: (n) ->
+               if n.type is 'text'
+                       n = n.parent
+                       return unless n?.el?
+               # which css rule should be used to preserve spaces (should we need to)
+               style = @iframe.contentWindow.getComputedStyle n.el, null
+               ws = style.getPropertyValue 'white-space'
+               if ws_props[ws].space
+                       preserve_rule = ws
+               else
+                       preserve_rule = ws_props[ws].to_preserve
+               preserve_rule = "white-space: #{preserve_rule}"
+               if @has_collapsable_space n
+                       # make sure preserve_rule exists
+                       if n.el.style['white-space']
+                               # FIXME check that it matches
+                               return
+                       if n.attrs[style]?
+                               n.attrs.style += "; #{preserve_rule}"
+                       else
+                               n.attrs.style = preserve_rule
+                       n.el.setAttribute 'style', n.attrs.style
+               else
+                       # remove preserve_rule if it exists
+                       return unless n.attrs.style?
+                       # FIXME don't assume whitespace is just so
+                       if n.attrs.style is "white-space: #{ws}"
+                               delete n.attrs.style
+                               n.el.removeAttribute 'style'
+                       else
+                               # FIXME find it in the middle and at the start
+                               needle = "; white-space: #{ws}"
+                               if needle is n.attrs.style.substr n.attrs.style.length - needle
+                                       n.attrs.style = n.attrs.style.substr 0, n.attrs.style.length - needle
+                                       n.el.setAttribute n.attrs.style
+       # after calling this, you MUST call changed() and adjust_whitespace_style()
+       insert_character: (n, i, char) ->
+               parent = @cursor.n.parent
+               return unless parent
+               return unless parent.el?
+               # insert the character
+               if i is 0
+                       n.text = char + n.text
+               else if i is n.text.length
+                       n.text += char
+               else
+                       n.text =
+                               n.text.substr(0, i) +
+                               char +
+                               n.text.substr(i)
+               n.el.nodeValue = n.text
+       # after calling this, you MUST call changed() and adjust_whitespace_style()
+       remove_character: (n, i) ->
+               n.text = n.text.substr(0, i) + n.text.substr(i + 1)
+               n.el.nodeValue = n.text
        kill_cursor: -> # remove it, forget where it was
                if @cursor_visible
                        @cursor_el.parentNode.removeChild @cursor_el