JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
convert editor to javascript, fix
authorJason Woofenden <jason@jasonwoof.com>
Sat, 13 May 2017 20:08:22 +0000 (16:08 -0400)
committerJason Woofenden <jason@jasonwoof.com>
Sat, 13 May 2017 20:08:22 +0000 (16:08 -0400)
.gitignore
Makefile
demo.html [new file with mode: 0644]
editor.coffee [deleted file]
editor.js [new file with mode: 0644]
editor_tests.coffee [deleted file]
editor_tests.html [deleted file]
editor_tests_coffee.html [deleted file]
editor_tests_compiled.html [deleted file]
index.html
parser.js

index 4a481e8..749a753 100644 (file)
@@ -1,3 +1 @@
-/editor.js
-/editor_tests.js
 /page_dark.css
index ec22ed0..c78d9b4 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -1,10 +1,7 @@
-OBJECTS= editor.js editor_tests.js page_dark.css
+OBJECTS= page_dark.css
 
 all: $(OBJECTS)
 
-%.js: %.coffee
-       coffee -c $< && sed -i -e 's/\(parser\|parser_no_browser_helper\)[.]coffee/\1.js/g' $@
-
 %.css: %.styl
        stylus $<
 
diff --git a/demo.html b/demo.html
new file mode 100644 (file)
index 0000000..a03a01d
--- /dev/null
+++ b/demo.html
@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+       <meta charset="UTF-8">
+       <link rel="icon" href="data:null">
+       <title>html editor tester</title>
+       <link rel="stylesheet" href="page_dark.css">
+       <style>
+               textarea {
+                       box-sizing: border-box;
+                       width: 100%;
+                       height: 200px
+               }
+               /* optional */
+               .peach_html5_editor {
+                       width: 500px;
+                       height: 500px;
+                       margin: 0 auto;
+               }
+       </style>
+</head>
+<body>
+       <h1>Peach HTML5 Editor test page (compiled version)</h1>
+       <p>This color scheme is just temporary, for testing the cursor and annotations on a variaty of background colors</p>
+       <form action="#" method="get">
+       <p>HTML view. Changes here propagate when you remove your cursor (press tab or click outside)<br><textarea name="in" id="in">&lt;H1>Headline!&lt;/h1>&lt;p&gt;  normal text that is hopefully long enough that it will wrap     around\rand
+spill onto a second line.&lt;/p>    &lt;p  >Text   with lots of extra whitespace
+
+
+
+       in the original html  and no closing p tag       &lt;p>normal paragraph&lt;/p>
+       &lt;p>testing &amp;lt;br&amp;gt; e f&lt;br>g   &lt;br>  h i j &lt;a href="http://example.com">Click me!</a> o p q r&lt;/p>
+       &lt;div style="border: 2px solid #fab">
+       &lt;p> y z     &lt;strong&gt;Bold &lt;em&gt; Italic + Bold&lt;/strong&gt; Italic &lt;/em&gt; Normal&lt;/p&gt;
+&lt;p style="white-space: pre-wrap"&gt;this &amp;lt;p&amp;gt; has     white-space: pre-wrap&lt;/p&gt;
+
+&lt;div style="color: black; background: white;"&gt;
+       &lt;div&gt;I'm in a div&lt;/div&gt;
+       &lt;div&gt;I'm in another div&lt;/div&gt;
+&lt;div&gt;
+&lt;div&gt;
+&amp;nbsp;
+&lt;/div&gt;
+&lt;/div&gt;
+&lt;/div&gt;
+&lt;p>  Above, there's a white div containing 3 divs. The third contains a div which contains just a non-breaking space (&amp;amp;nbsp;)&lt;/p>
+&lt;p>final paragraph.&lt;/p>
+&lt;/div>
+       </textarea></p>
+       <p><input id="button" type="submit" value="loading..." disabled></p>
+       </form>
+       <script src="parser.js"></script>
+       <script src="editor.js"></script>
+       <script src="demo.js"></script>
+       <p><a href="https://jasonwoof.com/gitweb/?p=peach-html5-editor.git;a=tree">Source</a> - <a href="https://gnu.org/licenses/agpl.html">AGPLv3+</a></p>
+</body>
+</html>
diff --git a/editor.coffee b/editor.coffee
deleted file mode 100644 (file)
index b21d959..0000000
+++ /dev/null
@@ -1,1799 +0,0 @@
-# Copyright 2015 Jason Woofenden
-# This file implements an WYSIWYG editor in the browser (no contenteditable)
-#
-# This program is free software: you can redistribute it and/or modify it under
-# the terms of the GNU Affero General Public License as published by the Free
-# Software Foundation, either version 3 of the License, or (at your option) any
-# later version.
-#
-# This program is distributed in the hope that it will be useful, but WITHOUT
-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-# FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-# details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-# SETTINGS
-overlay_padding = 10
-breathing_room = 30 # minimum pixels above/below cursor (scrolling)
-
-timeout = (ms, cb) -> return setTimeout cb, ms
-next_frame = (cb) ->
-       if (window.requestAnimationFrame?)
-               window.requestAnimationFrame cb
-       else
-               timeout 16, cb
-
-this_url_sans_path = ->
-       ret = "#{window.location.href}"
-       clip = ret.lastIndexOf '#'
-       if clip > -1
-               ret = ret.substr 0, clip
-       clip = ret.lastIndexOf '?'
-       if clip > -1
-               ret = ret.substr 0, clip
-       clip = ret.lastIndexOf '/'
-       if clip > -1
-               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
-               to_collapse: 'nowrap'
-       'pre-wrap':
-               space: true
-               newline: true
-               wrap: true
-               to_collapse: 'normal'
-
-# 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"
-js_attr_regex = new RegExp '^[oO][nN].'
-# html5 spec says that only these characters are collapsable
-multi_sp_regex = new RegExp '[\u0020\u0009\u000a\u000c\u000d][\u0020\u0009\u000a\u000c\u000d]'
-
-str_has_ws_run = (str) ->
-       return multi_sp_regex.test str
-
-# text nodes don't have getBoundingClientRect(), so use selection api to find
-# it.
-get_el_bounds = window.bounds = (el) ->
-       if el.getBoundingClientRect?
-               rect = el.getBoundingClientRect()
-       else
-               # text nodes don't have getBoundingClientRect(), so use range api
-               range = el.ownerDocument.createRange()
-               range.selectNodeContents el
-               rect = range.getBoundingClientRect()
-       doc = el.ownerDocument.documentElement
-       win = el.ownerDocument.defaultView
-       y_fix = win.pageYOffset - doc.clientTop
-       x_fix = win.pageXOffset - doc.clientLeft
-       return {
-               x: rect.left + x_fix
-               y: rect.top + y_fix
-               w: rect.width ? (rect.right - rect.left)
-               h: rect.height ? (rect.top - rect.bottom)
-       }
-
-is_display_block = (el) ->
-       if el.currentStyle?
-               return el.currentStyle.display is 'block'
-       else
-               return window.getComputedStyle(el, null).getPropertyValue('display') is 'block'
-
-# Pass return value from dom event handlers to this.
-# If they return false, this will addinionally stop propagation and default.
-event_return = (e, bool) ->
-       if bool is false
-               if e.stopPropagation?
-                       e.stopPropagation()
-               if e.preventDefault?
-                       e.preventDefault()
-       return bool
-# Warning: currently assumes you're asking about a single character
-# Note: chromium returns multiple bounding rects for a space at a line-break
-# Note: chromium's getBoundingClientRect() is broken (when zero-area client rects)
-# Note: sometimes returns null (eg for whitespace that is not visible)
-text_range_bounds = (el, start, end) ->
-       range = document.createRange()
-       range.setStart el, start
-       range.setEnd el, end
-       rects = range.getClientRects()
-       if rects.length > 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
-       win = el.ownerDocument.defaultView
-       y_fix = win.pageYOffset - doc.clientTop
-       x_fix = win.pageXOffset - doc.clientLeft
-       return {
-               x: rect.left + x_fix
-               y: rect.top + y_fix
-               w: rect.width ? (rect.right - rect.left)
-               h: rect.height ? (rect.top - rect.bottom)
-               rects: rects
-               bounding: range.getBoundingClientRect()
-       }
-
-class CursorPosition
-       constructor: (args) ->
-               @n = args.n ? null
-               @i = args.i ? null
-               if args.x?
-                       @x = args.x
-                       @y = args.y
-                       @h = args.h
-               else
-                       @set_xyh()
-               return
-       set_xyh: ->
-               range = document.createRange()
-               if @n.text.length is 0
-                       ret = text_range_bounds @n.el, 0, 0
-               else if @i is @n.text.length
-                       ret = text_range_bounds @n.el, @i - 1, @i
-                       if ret?
-                               ret.x += ret.w
-               else
-                       ret = text_range_bounds @n.el, @i, @i + 1
-               if ret?
-                       @x = ret.x
-                       @y = ret.y
-                       @h = ret.h
-               else
-                       @x = null
-                       @y = null
-                       @h = null
-               return ret
-
-new_cursor_position = (args) ->
-       ret = new CursorPosition args
-       if ret.x?
-               return ret
-       return null
-
-# encode text so it can be safely placed inside an html attribute
-enc_attr_regex = new RegExp '(&)|(")|(\u00A0)', 'g'
-enc_attr = (txt) ->
-       return txt.replace enc_attr_regex, (match, amp, quote) ->
-               return '&amp;' if (amp)
-               return '&quot;' if (quote)
-               return '&nbsp;'
-enc_text_regex = new RegExp '(&)|(<)|(\u00A0)', 'g'
-enc_text = (txt) ->
-       return txt.replace enc_text_regex, (match, amp, lt) ->
-               return '&amp;' if (amp)
-               return '&lt;' if (lt)
-               return '&nbsp;'
-
-void_elements = {
-       area: true
-       base: true
-       br: true
-       col: true
-       embed: true
-       hr: true
-       img: true
-       input: true
-       keygen: true
-       link: true
-       meta: true
-       param: true
-       source: true
-       track: true
-       wbr: true
-}
-# TODO make these always pretty-print (on the inside) like blocks
-no_text_elements = { # these elements never contain text
-       select: true
-       table: true
-       tr: true
-       thead: true
-       tbody: true
-       ul: true
-       ol: true
-}
-
-domify = (doc, hash) ->
-       for tag, attrs of hash
-               if tag is 'text'
-                       return document.createTextNode attrs
-               el = document.createElement tag
-               for k, v of attrs
-                       if k is 'children'
-                               for child in v
-                                       el.appendChild child
-                       else
-                               el.setAttribute k, v
-       return el
-
-
-
-ignore_key_codes =
-       '18': true # alt
-       '20': true # capslock
-       '17': true # ctrl
-       '144': true # numlock
-       '16': true # shift
-       '91': true # windows "start" key
-# key codes: (valid on keydown, not keypress)
-KEY_LEFT = 37
-KEY_UP = 38
-KEY_RIGHT = 39
-KEY_DOWN = 40
-KEY_BACKSPACE = 8 # <--
-KEY_DELETE = 46 # -->
-KEY_END = 35
-KEY_ENTER = 13
-KEY_ESCAPE = 27
-KEY_HOME = 36
-KEY_INSERT = 45
-KEY_PAGE_UP = 33
-KEY_PAGE_DOWN = 34
-KEY_TAB = 9
-control_key_codes = # we react to these, but they aren't typing
-       '37': KEY_LEFT
-       '38': KEY_UP
-       '39': KEY_RIGHT
-       '40': KEY_DOWN
-       '35': KEY_END
-       '8':  KEY_BACKSPACE
-       '46': KEY_DELETE
-       '13': KEY_ENTER
-       '27': KEY_ESCAPE
-       '36': KEY_HOME
-       '45': KEY_INSERT
-       '33': KEY_PAGE_UP
-       '34': KEY_PAGE_DOWN
-       '9':  KEY_TAB
-
-instantiate_tree = (tree, parent) ->
-       remove = []
-       for c, i in tree
-               switch c.type
-                       when 'text'
-                               c.el = parent.ownerDocument.createTextNode c.text
-                               parent.appendChild c.el
-                       when 'tag'
-                               if c.name in ['script', 'object', 'iframe', 'link']
-                                       # TODO put placeholders instead
-                                       remove.unshift i
-                                       continue
-                               # TODO create in correct namespace
-                               c.el = parent.ownerDocument.createElement c.name
-                               for k, v of c.attrs
-                                       # FIXME if attr_whitelist[k]?
-                                       if valid_attr_regex.test k
-                                               unless js_attr_regex.test k
-                                                       c.el.setAttribute k, v
-                               parent.appendChild c.el
-                               if c.children.length
-                                       instantiate_tree c.children, c.el
-       for i in remove
-               tree.splice i, 1
-
-traverse_tree = (tree, cb) ->
-       done = false
-       for c in tree
-               done = cb c
-               return done if done
-               if c.children.length
-                       done = traverse_tree c.children, cb
-                       return done if done
-       return done
-
-first_cursor_position = (tree) ->
-       found = null
-       traverse_tree tree, (node, state) ->
-               if node.type is 'text'
-                       cursor = new_cursor_position n: node, i: 0
-                       if cursor?
-                               found = cursor
-                               return true # done traversing
-               return false # not done traversing
-       return found # maybe null
-
-# this will fail when text has non-locatable cursor positions
-find_next_cursor_position = (tree, cursor) ->
-       if cursor.n.type is 'text' and cursor.n.text.length > cursor.i
-               new_cursor = new_cursor_position n: cursor.n, i: cursor.i + 1
-               if new_cursor?
-                       return new_cursor
-       state_before = true
-       found = null
-       traverse_tree tree, (node, state) ->
-               if node.type is 'text' and state_before is false
-                       new_cursor = new_cursor_position n: node, i: 0
-                       if new_cursor?
-                               found = new_cursor
-                               return true # done traversing
-               if node is cursor.n
-                       state_before = false
-               return false # not done traversing
-       if found?
-               return found
-       return null
-
-last_cursor_position = (tree) ->
-       found = null
-       traverse_tree tree, (node) ->
-               if node.type is 'text'
-                       cursor = new_cursor_position n: node, i: node.text.length
-                       if cursor?
-                               found = cursor
-               return false # not done traversing
-       return found # maybe null
-
-# this will fail when text has non-locatable cursor positions
-find_prev_cursor_position = (tree, cursor) ->
-       if cursor.n.type is 'text' and cursor.i > 0
-               new_cursor = new_cursor_position n: cursor.n, i: cursor.i - 1
-               if new_cursor?
-                       return new_cursor
-       found_prev = null
-       found = null
-       traverse_tree tree, (node) ->
-               if node is cursor.n
-                       found = found_prev # maybe null
-                       return true # done traversing
-               if node.type is 'text'
-                       new_cursor = new_cursor_position n: node, i: node.text.length
-                       if new_cursor?
-                               found_prev = new_cursor
-               return false # not done traversing
-       return found # maybe null
-
-find_up_cursor_position = (tree, cursor, ideal_x) ->
-       new_cursor = cursor
-       # go prev until we're higher on y axis
-       while new_cursor.y >= cursor.y
-               new_cursor = find_prev_cursor_position tree, new_cursor
-               return null unless new_cursor?
-       # done early if we're already left of old cursor position
-       if new_cursor.x <= ideal_x
-               return new_cursor
-       target_y = new_cursor.y
-       # search leftward, until we find the closest position
-       # new_cursor is the prev-most position we've checked
-       # prev_cursor is the older value, so it's not as prev as new_cursor
-       while new_cursor.x > ideal_x and new_cursor.y is target_y
-               prev_cursor = new_cursor
-               new_cursor = find_prev_cursor_position tree, new_cursor
-               break unless new_cursor?
-       # move cursor to prev_cursor or new_cursor
-       if new_cursor?
-               if new_cursor.y is target_y
-                       # both valid, and on the same line, use closest
-                       if (ideal_x - new_cursor.x) < (prev_cursor.x - ideal_x)
-                               return new_cursor
-                       else
-                               return prev_cursor
-               else
-                       # new_cursor on wrong line, use prev_cursor
-                       return prev_cursor
-       else
-               # can't go any further prev, use prev_cursor
-               return prev_cursor
-
-find_down_cursor_position = (tree, cursor, ideal_x) ->
-       new_cursor = cursor
-       # go next until we move on the y axis
-       while new_cursor.y <= cursor.y
-               new_cursor = find_next_cursor_position tree, new_cursor
-               return null unless new_cursor?
-       # done early if we're already right of old cursor position
-       if new_cursor.x >= ideal_x
-               # this would be strange, but could happen due to runaround
-               return new_cursor
-       target_y = new_cursor.y
-       # search rightward, until we find the closest position
-       # new_cursor is the next-most position we've checked
-       # prev_cursor is the older value, so it's not as next as new_cursor
-       while new_cursor.x < ideal_x and new_cursor.y is target_y
-               prev_cursor = new_cursor
-               new_cursor = find_next_cursor_position tree, new_cursor
-               break unless new_cursor?
-       # move cursor to prev_cursor or new_cursor
-       if new_cursor?
-               if new_cursor.y is target_y
-                       # both valid, and on the same line, use closest
-                       if (new_cursor.x - ideal_x) < (ideal_x - prev_cursor.x)
-                               return new_cursor
-                       else
-                               return prev_cursor
-               else
-                       # new_cursor on wrong line, use prev_cursor
-                       return prev_cursor
-       else
-               # can't go any further prev, use prev_cursor
-               return prev_cursor
-
-xy_to_cursor = (tree, xy) ->
-       for n in tree
-               if n.type is 'tag' or n.type is 'text'
-                       bounds = get_el_bounds n.el
-                       continue if xy.x < bounds.x
-                       continue if xy.x > bounds.x + bounds.w
-                       continue if xy.y < bounds.y
-                       continue if xy.y > bounds.y + bounds.h
-                       if n.children.length
-                               ret = xy_to_cursor n.children, xy
-                               return ret if ret?
-                       if n.type is 'text'
-                               # click is within bounding box that contains all text.
-                               if n.text.length is 0
-                                       ret = new_cursor_position n: n, i: 0
-                                       return ret if ret?
-                                       continue
-                               before = new_cursor_position n: n, i: 0
-                               continue unless before?
-                               after = new_cursor_position n: n, i: n.text.length
-                               continue unless after?
-                               if xy.y < before.y + before.h and xy.x < before.x
-                                       # console.log 'before first char on first line'
-                                       continue
-                               if xy.y > after.y and xy.x > after.x
-                                       # console.log 'after last char on last line'
-                                       continue
-                               if xy.y < before.y
-                                       console.log "Warning: click in text bounding box but above first line"
-                                       continue # above first line (runaround?)
-                               if xy.y > after.y + after.h
-                                       console.log "Warning: click in text bounding box but below last line", xy.y, after.y, after.h
-                                       continue # below last line (shouldn't happen?)
-                               while after.i - before.i > 1
-                                       guess_i = Math.round((before.i + after.i) / 2)
-                                       cur = new_cursor_position n: n, i: guess_i
-                                       unless cur?
-                                               console.log "error: failed to find cursor pixel location for", n, guess_i
-                                               before = null
-                                               break
-                                       if xy.y < cur.y or (xy.y <= cur.y + cur.h and xy.x < cur.x)
-                                               after = cur
-                                       else
-                                               before = cur
-                               continue unless before? # signals failure to find a cursor position
-                               # which one is closest?
-                               if Math.abs(before.x - xy.x) < Math.abs(after.x - xy.x)
-                                       return before
-                               else
-                                       return after
-       return null
-
-# browsers collapse these (html5 spec calls these "space characters")
-is_space_code = (char_code) ->
-       switch char_code
-               when 9, 10, 12, 13, 32
-                       return true
-       return false
-is_space = (chr) ->
-       return is_space_code chr.charCodeAt 0
-
-tree_remove_empty_text_nodes = (tree) ->
-       empties = []
-       traverse_tree tree, (n) ->
-               if n.type is 'text'
-                       if n.text.length is 0
-                               empties.unshift n
-               return false # not done traversing
-       for n in empties
-               # don't completely empty the tree
-               if tree.length is 1
-                       if tree[0].type is 'text'
-                               console.log "oop, leaving a blank node because it's the only thing"
-                               return
-               n.el.parentNode.removeChild n.el
-               for c, i in n.parent.children
-                       if c is n
-                               n.parent.children.splice i, 1
-                               break
-
-class PeachHTML5Editor
-       # Options: (all optional)
-       #   editor_id: "id" attribute for outer-most element created by/for editor
-       #   css_file: filename of a css file to style editable content
-       #   on_init: callback for when the editable content is in place
-       constructor: (in_el, options) ->
-               @options = options ? {}
-               @in_el = in_el
-               @tree = null # array of Nodes, all editable content
-               @tree_parent = null # @tree is this.children. .el might === @idoc.body
-               @matting = []
-               @init_1_called = false # when iframes have loaded
-               @outer_iframe # iframe to hold editor
-               @outer_idoc # "document" object for @outer_iframe
-               @wrap2 = null # scrollbar is on this
-               @wrap2_offset = null
-               @wrap2_height = null # including padding
-               @iframe = null # iframe to hold editable content
-               @idoc = null # "document" object for @iframe
-               @cursor = null
-               @cursor_el = null
-               @cursor_visible = false
-               @cursor_ideal_x = null
-               @poll_for_blur_timeout = null
-               opt_fragment = @options.fragment ? true
-               @parser_opts = {}
-               if opt_fragment
-                       @parser_opts.fragment = 'body'
-
-               @outer_iframe = domify document, iframe: {}
-               outer_iframe_style = 'border: none !important; margin: 0 !important; padding: 0 !important; height: 100% !important; width: 100% !important;'
-               if @options.editor_id?
-                       @outer_iframe.setAttribute 'id', @options.editor_id
-               @outer_iframe.onload = =>
-                       @outer_idoc = @outer_iframe.contentDocument
-                       icss = domify @outer_idoc, style: children: [
-                               domify @outer_idoc, text: css
-                       ]
-                       @outer_idoc.head.appendChild icss
-                       @iframe = domify @outer_idoc, iframe: sandbox: 'allow-same-origin allow-scripts'
-                       @iframe.onload = =>
-                               @init_1()
-                       timeout 200, => # firefox never fires this onload
-                               @init_1() unless @init_1_called
-                       @outer_idoc.body.appendChild(
-                               domify @outer_idoc, div: id: 'wrap1', children: [
-                                       domify @outer_idoc, div: style: "position: absolute; top: 0; left: 1px; font-size: 10px", children: [ domify @outer_idoc, text: "Peach HTML5 Editor" ]
-                                       @wrap2 = domify @outer_idoc, div: id: 'wrap2', children: [
-                                               domify @outer_idoc, div: id: 'wrap3', children: [
-                                                       @iframe
-                                                       @overlay = domify @outer_idoc, div: id: 'overlay'
-                                               ]
-                                       ]
-                               ]
-                       )
-               outer_wrap = domify document, div: class: 'peach_html5_editor'
-               @in_el.parentNode.appendChild outer_wrap
-               outer_bounds = get_el_bounds outer_wrap
-               if outer_bounds.w < 300
-                       outer_bounds.w = 300
-               if outer_bounds.h < 300
-                       outer_bounds.h = 300
-               outer_iframe_style += "width: #{outer_bounds.w}px; height: #{outer_bounds.h}px;"
-               @outer_iframe.setAttribute 'style', outer_iframe_style
-               css = @generate_outer_css w: outer_bounds.w, h: outer_bounds.h
-               outer_wrap.appendChild @outer_iframe
-       init_1: -> # @iframe has loaded (but not it's css)
-               @idoc = @iframe.contentDocument
-               @init_1_called = true
-               # chromium doesn't resolve relative urls as though they were at the same domain
-               # so add a <base> tag
-               @idoc.head.appendChild domify @idoc, base: href: this_url_sans_path()
-               # don't let @iframe have scrollbars
-               @idoc.head.appendChild domify @idoc, style: children: [domify @idoc, text: "body { overflow: hidden; }"]
-               # load css file
-               if @options.css_file
-                       istyle = domify @idoc, link: rel: 'stylesheet', href: @options.css_file
-                       istyle.onload = =>
-                               @init_2()
-                       @idoc.head.appendChild istyle
-               else
-                       @init_2()
-       init_2: -> # @iframe and it's css file(s) are ready
-               @overlay.onclick = (e) =>
-                       @have_focus()
-                       return event_return e, @onclick e
-               @overlay.ondoubleclick = (e) =>
-                       @have_focus()
-                       return event_return e, @ondoubleclick e
-               @outer_idoc.body.onkeyup = (e) =>
-                       @have_focus()
-                       return event_return e, @onkeyup e
-               @outer_idoc.body.onkeydown = (e) =>
-                       @have_focus()
-                       return event_return e, @onkeydown e
-               @outer_idoc.body.onkeypress = (e) =>
-                       @have_focus()
-                       return event_return e, @onkeypress e
-               @load_html @in_el.value
-               if @options.on_init?
-                       @options.on_init()
-       generate_outer_css: (args) ->
-               w = args.w ? 300
-               h = args.h ? 300
-               inner_padding = args.inner_padding ? overlay_padding
-               frame_width = args.frame_width ? inner_padding
-               occupy = (left, top = left, right = left, bottom = top) ->
-                       w -= left + right
-                       h -= top + bottom
-                       return Math.max(left, top, right, bottom)
-               ret = ''
-               ret += 'body {'
-               ret +=     'margin: 0;'
-               ret +=     'padding: 0;'
-               ret +=     'color: black;'
-               ret +=     'background: white;'
-               ret += '}'
-               ret += '#wrap1 {'
-               ret +=     "border: #{occupy 1}px solid black;"
-               ret +=     "padding: #{occupy frame_width}px;"
-               ret += '}'
-               ret += '#wrap2 {'
-               ret +=     "border: #{occupy 1}px solid black;"
-               @wrap2_height = h # including padding because padding scrolls
-               ret +=     "padding: #{occupy inner_padding}px;"
-               ret +=     "padding-right: #{inner_padding + occupy 0, 0, 15, 0}px;" # for scroll bar
-               ret +=     "width: #{w}px;"
-               ret +=     "height: #{h}px;"
-               ret +=     'overflow-x: hidden;'
-               ret +=     'overflow-y: scroll;'
-               ret += '}'
-               ret += '#wrap3 {'
-               ret +=     'position: relative;'
-               ret +=     "width: #{w}px;"
-               ret +=     "min-height: #{h}px;"
-               ret += '}'
-               ret += 'iframe {'
-               ret +=     'box-sizing: border-box;'
-               ret +=     'margin: 0;'
-               ret +=     'border: none;'
-               ret +=     'padding: 0;'
-               ret +=     "width: #{w}px;"
-               #ret +=     "height: #{h}px;" # height auto-set when content set/changed
-               ret +=     '-ms-user-select: none;'
-               ret +=     '-webkit-user-select: none;'
-               ret +=     '-moz-user-select: none;'
-               ret +=     'user-select: none;'
-               ret += '}'
-               ret += '#overlay {'
-               ret +=     'position: absolute;'
-               ret +=     "left: -#{inner_padding}px;"
-               ret +=     "top: -#{inner_padding}px;"
-               ret +=     "right: -#{inner_padding}px;"
-               ret +=     "bottom: -#{inner_padding}px;"
-               ret +=     'overflow: hidden;'
-               ret += '}'
-               ret += '.lightbox {'
-               ret +=     'position: absolute;'
-               ret +=     'background: rgba(100,100,100,0.2);'
-               ret += '}'
-               ret += '#cursor {'
-               ret +=     'position: absolute;'
-               ret +=     'width: 2px;'
-               ret +=     'background: linear-gradient(0deg, rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1));'
-               ret +=     'background-size: 200% 200%;'
-               ret +=     '-webkit-animation: blink 1s linear normal infinite;'
-               ret +=     'animation: blink 1s linear normal infinite;'
-               ret += '}'
-               ret += '@-webkit-keyframes blink {'
-               ret +=     '0%{background-position:0% 0%}'
-               ret +=     '100%{background-position:0% -100%}'
-               ret += '}'
-               ret += '@keyframes blink { '
-               ret +=     '0%{background-position:0% 0%}'
-               ret +=     '100%{background-position:0% -100%}'
-               ret += '}'
-               ret += '.ann_box {'
-               ret +=     'z-index: 5;'
-               ret +=     'position: absolute;'
-               ret +=     'border: 1px solid rgba(0,0,0,0.1);'
-               ret +=     'outline: 1px solid rgba(255,255,255,0.1);' # in case there's a black background
-               ret += '}'
-               ret += '.ann_tag {'
-               ret +=     'z-index: 10;'
-               ret +=     'position: absolute;'
-               ret +=     'font-size: 8px;'
-               ret +=     'white-space: pre;'
-               ret +=     'background: rgba(255,255,255,0.4);'
-               ret +=     '-ms-user-select: none;'
-               ret +=     '-webkit-user-select: none;'
-               ret +=     '-moz-user-select: none;'
-               ret +=     'user-select: none;'
-               ret += '}'
-               return ret
-       overlay_event_to_inner_xy: (e) ->
-               unless @wrap2_offset?
-                       @wrap2_offset = get_el_bounds @wrap2
-               x = e.pageX - overlay_padding
-               y = e.pageY - overlay_padding + @wrap2.scrollTop
-               return x: x - @wrap2_offset.x, y: y - @wrap2_offset.y
-       onclick: (e) ->
-               xy = @overlay_event_to_inner_xy e
-               new_cursor = xy_to_cursor @tree, xy
-               if new_cursor?
-                       @move_cursor new_cursor
-               else
-                       @kill_cursor()
-               return false
-       ondoubleclick: (e) ->
-               return false
-       onkeyup: (e) ->
-               return if e.ctrlKey
-               return false if ignore_key_codes[e.keyCode]?
-               #return false if control_key_codes[e.keyCode]?
-       onkeydown: (e) ->
-               return if e.ctrlKey
-               return false if ignore_key_codes[e.keyCode]?
-               #return false if control_key_codes[e.keyCode]?
-               switch e.keyCode
-                       when KEY_LEFT
-                               if @cursor?
-                                       new_cursor = find_prev_cursor_position @tree, @cursor
-                               else
-                                       new_cursor = first_cursor_position @tree
-                               if new_cursor?
-                                       @move_cursor new_cursor
-                               return false
-                       when KEY_RIGHT
-                               if @cursor?
-                                       new_cursor = find_next_cursor_position @tree, @cursor
-                               else
-                                       new_cursor = last_cursor_position @tree
-                               if new_cursor?
-                                       @move_cursor new_cursor
-                               return false
-                       when KEY_UP
-                               if @cursor?
-                                       new_cursor = find_up_cursor_position @tree, @cursor, @cursor_ideal_x
-                                       if new_cursor?
-                                               saved_ideal_x = @cursor_ideal_x
-                                               @move_cursor new_cursor
-                                               @cursor_ideal_x = saved_ideal_x
-                               else
-                                       # move cursor to first position in document
-                                       new_cursor = first_cursor_position @tree
-                                       if new_cursor?
-                                               @move_cursor new_cursor
-                               return false
-                       when KEY_DOWN
-                               if @cursor?
-                                       new_cursor = find_down_cursor_position @tree, @cursor, @cursor_ideal_x
-                                       if new_cursor?
-                                               saved_ideal_x = @cursor_ideal_x
-                                               @move_cursor new_cursor
-                                               @cursor_ideal_x = saved_ideal_x
-                               else
-                                       # move cursor to first position in document
-                                       new_cursor = last_cursor_position @tree
-                                       if new_cursor?
-                                               @move_cursor new_cursor
-                               return false
-                       when KEY_END
-                               new_cursor = last_cursor_position @tree
-                               if new_cursor?
-                                       @move_cursor new_cursor
-                               return false
-                       when KEY_BACKSPACE
-                               @on_key_backspace e
-                               return false
-                       when KEY_DELETE
-                               return false unless @cursor?
-                               new_cursor = find_next_cursor_position @tree, n: @cursor.n, i: @cursor.i
-                               # try moving cursor right and then running backspace code
-                               # TODO replace this hack with a real implementation
-                               if new_cursor?
-                                       # try to detect common case where cursor goes inside an block,
-                                       # but doesn't pass a character (and advance one more in that case)
-                                       if new_cursor.n isnt @cursor.n and new_cursor.i is 0
-                                               if new_cursor.n.type is 'text' and new_cursor.n.text.length > 0
-                                                       if new_cursor.n.parent?
-                                                               unless @is_display_block new_cursor.n.parent
-                                                                       # FIXME should test run sibling
-                                                                       new_cursor = new_cursor_position n: new_cursor.n, i: new_cursor.i + 1
-                               if new_cursor?
-                                       if new_cursor.n isnt @cursor.n or new_cursor.i isnt @cursor.i
-                                               @move_cursor new_cursor
-                                               @on_key_backspace e
-                               return false
-                       when KEY_ENTER
-                               @on_key_enter e
-                               return false
-                       when KEY_ESCAPE
-                               @kill_cursor()
-                               return false
-                       when KEY_HOME
-                               new_cursor = first_cursor_position @tree
-                               if new_cursor?
-                                       @move_cursor new_cursor
-                               return false
-                       when KEY_INSERT
-                               return false
-                       when KEY_PAGE_UP
-                               @on_page_up_key e
-                               return false
-                       when KEY_PAGE_DOWN
-                               @on_page_down_key e
-                               return false
-                       when KEY_TAB
-                               return false
-       onkeypress: (e) ->
-               return if e.ctrlKey
-               return false if ignore_key_codes[e.keyCode]?
-               char = e.charCode ? e.keyCode
-               if char and @cursor?
-                       char = String.fromCharCode char
-                       @insert_character @cursor.n, @cursor.i, char
-                       @text_cleanup @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
-       on_key_enter: (e) -> # enter key pressed
-               return unless @cursor_visible
-               cur_block = @cursor.n
-               loop
-                       if cur_block.type is 'tag'
-                               if is_display_block cur_block.el
-                                       break
-                       return unless cur_block.parent?
-                       cur_block = cur_block.parent
-               # find array to insert new element into
-               if cur_block.parent is @tree_parent # top-level
-                       parent_el = @idoc.body
-                       pc = @tree
-               else
-                       parent_el = cur_block.parent.el
-                       pc = cur_block.parent.children
-               # find index of current block in its parent
-               for n, i in pc
-                       break if n is cur_block
-               i += 1 # we want to be after it
-               if i < pc.length
-                       before = pc[i].el
-               else
-                       before = null
-               # TODO if content after cursor
-               #       TODO new block is empty
-               new_text = new peach_parser.Node 'text', text: ' '
-               new_node = new peach_parser.Node 'tag', name: 'p', parent: cur_block.parent, attrs: {style: 'white-space: pre-wrap'}, children: [new_text]
-               new_text.parent = new_node
-               new_text.el = domify @idoc, text: ' '
-               new_node.el = domify @idoc, p: style: 'white-space: pre-wrap', children: [new_text.el]
-               pc.splice i, 0, new_node
-               parent_el.insertBefore new_node.el, before
-               @changed()
-               new_cursor = new_cursor_position n: new_text, i: 0
-               throw 'bork bork' unless new_cursor?
-               @move_cursor new_cursor
-               # TODO move content past cursor into this new block
-       # unlike the global function, this takes a Node, not an element
-       is_display_block: (n) ->
-               # TODO stop calling global function, merge it into here, use iframe's window object
-               return false unless n.type is 'tag'
-               return is_display_block n.el
-       find_block_parent: (n) ->
-               loop
-                       n = n.parent
-                       return null unless n?
-                       return n if @is_display_block n
-                       return n if n is @tree_parent
-               return null
-       # return a flat array of nodes (text, <br>, and later also inline-block)
-       # that are flowing/wrapping together. n can be the containing block, or any
-       # element inside it.
-       get_text_run: (n) ->
-               ret = []
-               if @is_display_block n
-                       block = n
-               else
-                       block = @find_block_parent n
-                       return ret unless block?
-               traverse_tree block.children, (n) =>
-                       if n.type is 'text'
-                               ret.push n
-                       else if n.type is 'tag'
-                               if n.name is 'br'
-                                       ret.push n
-                               else
-                                       disp = @computed_style n
-                                       if disp is 'inline-block'
-                                               ret.push n
-                       return false # not done traversing
-               return ret
-       node_is_decendant: (young, old) ->
-               while young? and young != @tree_parent
-                       return true if young is old
-                       young = young.parent
-               return false
-       # helper for on_key_backspace
-       _merge_left: (state) ->
-               # the node prev to n was not prev to it a moment ago, merge with it if reasonable
-               pi = state.n.parent.children.indexOf(state.n)
-               if pi > 0
-                       prev = state.n.parent.children[pi - 1]
-                       if prev.type is 'text'
-                               state.i = prev.text.length
-                               prev.text = prev.el.textContent = prev.text + state.n.text
-                               @remove_node state.n
-                               state.n = prev
-                               state.changed = true
-                               state.moved_cursor = true
-               # else # TODO merge possible consecutive matching inline tags at @cursor
-               return state
-       # helper for on_key_backspace
-       # remove n from the dom, also remove its inline parents that are emptied by removing n
-       _backspace_node_helper: (n, run = @get_text_run(n), run_i = run.indexOf(n)) ->
-               block = @find_block_parent n
-               # delete text node
-               @remove_node n
-               # delete any inline parents
-               n = n.parent
-               while n? and n isnt block
-                       # bail if the previous node in this run is also inside the same parent
-                       if run_i > 0
-                               break if @node_is_decendant run[run_i - 1], n
-                       # bail if the next node in this run is also inside the same parent
-                       if run_i + 1 < run.length
-                               break if @node_is_decendant run[run_i + 1], n
-                       # move any sibling nodes to parent. These nodes are not in the text run
-                       while n.children.length > 0
-                               @move_node n.children[0], n.parent, n
-                       # remove (now completely empty) inline parent
-                       @remove_node n
-                       # proceed to outer parent
-                       n = n.parent
-               return
-       on_key_backspace: (e) ->
-               return unless @cursor?
-               new_cursor = null
-               run = null
-               changed = true
-               if @cursor.i is 0 # cursor is at start of text node
-                       run ?= @get_text_run @cursor.n
-                       run_i = run.indexOf(@cursor.n)
-                       if run_i is 0 # if at start of text run
-                               block = @find_block_parent @cursor.n
-                               prev_cursor = find_prev_cursor_position @tree, n: @cursor.n, i: 0
-                               if prev_cursor is null # if in first text run of document
-                                       # do nothing (there's nothing text-like to the left of the cursor)
-                                       return
-                               # else merge with prev/outer text run
-                               pcb = @find_block_parent prev_cursor.n
-                               while block.children.length > 0
-                                       @move_node block.children[0], pcb
-                               @remove_node block
-                               # merge possible consecutive text nodes at @cursor
-                               merge_state = n: @cursor.n
-                               @_merge_left merge_state
-                               @text_cleanup merge_state.n
-                               new_cursor = new_cursor_position n: merge_state.n, i: merge_state.i
-                       else # at start of text node, but not start of text run
-                               prev = run[run_i - 1]
-                               if prev.type is 'text' # if previous in text run is text
-                                       if prev.text.length is 1 # if emptying prev (in text run)
-                                               @_backspace_node_helper prev, run, run_i
-                                               merge_state = n: @cursor.n, i: @cursor.i
-                                               @_merge_left merge_state
-                                               @text_cleanup merge_state.n
-                                               new_cursor = new_cursor_position n: merge_state.n, i: merge_state.i
-                                       else # prev in run is text with muliple chars
-                                               # delete last character in prev
-                                               prev.text = prev.text.substr(0, prev.text.length - 1)
-                                               prev.el.textContent = prev.text
-                                               @text_cleanup @cursor.n
-                                               new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i
-                               else if prev.name is 'br' or prev.name is 'hr'
-                                       @_backspace_node_helper prev, run, run_i
-                                       merge_state = n: @cursor.n, i: @cursor.i
-                                       @_merge_left merge_state
-                                       @text_cleanup merge_state.n
-                                       new_cursor = new_cursor_position n: merge_state.n, i: merge_state.i
-                               # FIXME implement this:
-                               # else # if prev (in run) is inline-block
-                                       # if that inline-block has text in it
-                                               # delete last char in prev inlineblock
-                                               # if that empties it
-                                                       # delete it
-                                                       # merge left
-                                               # else
-                                                       # move cursor inside
-                                       # else
-                                               # delete prev (inline) block
-                                               # merge left
-                                       # auto-delete this @cursor.parent(s) if this empties them
-               else # cursor is not at start of text node
-                       run ?= @get_text_run @cursor.n
-                       if @cursor.n.text.length is 1 # if emptying text node
-                               if run.length is 1 # if emptying text run (of text/br/hr/inline-block)
-                                       # remove inline-parents of @cursor.n
-                                       block = @find_block_parent @cursor.n
-                                       changed = false
-                                       n = @cursor.n.parent
-                                       # note: this doesn't use _backspace_node_helper because:
-                                       # 1. we don't want to delete the target node (we're replacing it's contents)
-                                       # 2. we want to track whether anything was removed
-                                       # 3. we know already know there's no other text from this run anywhere
-                                       while n and n isnt block
-                                               changed = true
-                                               while n.children.length > 0
-                                                       @move_node n.children[0], n.parent, n
-                                               @remove_node n
-                                               n = n.parent
-                                       # replace @cursor.n with a single (preserved) space
-                                       if @cursor.n.text != ' '
-                                               changed = true
-                                               @cursor.n.text = @cursor.n.el.textContent = ' '
-                                       if changed
-                                               @text_cleanup @cursor.n
-                                       # place the cursor to the left of that space
-                                       new_cursor = new_cursor_position n: @cursor.n, i: 0
-                               else # emptying a text node (but not a whole text run)
-                                       # figure out where cursor should land
-                                       block = @find_block_parent @cursor.n
-                                       new_cursor = find_prev_cursor_position @tree, n: @cursor.n, i: 0
-                                       ncb = @find_block_parent new_cursor.n
-                                       if ncb isnt block
-                                               new_cursor = find_next_cursor_position @tree, n: @cursor.n, i: 1
-                                       # delete text node and cleanup emptied parents
-                                       run_i = run.indexOf @cursor.n
-                                       @_backspace_node_helper @cursor.n, run, run_i
-                                       # see if new adjacent siblings should merge
-                                       # TODO make smarter
-                                       if run_i > 0 and run_i + 1 < run.length
-                                               if run[run_i - 1].type is 'text' and run[run_i + 1].type is 'text'
-                                                       merge_state = n: run[run_i + 1]
-                                                       @_merge_left merge_state
-                                                       if merge_state.moved_cursor
-                                                               new_cursor = merge_state
-                                       # update whitespace preservation
-                                       @text_cleanup(block)
-                                       # update cursor x/y in case things moved around
-                                       if new_cursor?
-                                               if new_cursor.n.el.parentNode # still in dom after cleanup
-                                                       new_cursor = new_cursor_position n: new_cursor.n, i: new_cursor.i
-                                               else
-                                                       new_cursor = null
-                       else # there's a char left of cursor that we can delete without emptying anything
-                               # delete character
-                               need_text_cleanup = true
-                               if @cursor.i > 1 and @cursor.i < @cursor.n.text.length
-                                       pre = @cursor.n.text.substr(@cursor.i - 2, 3)
-                                       post = pre.charAt(0) + pre.charAt(2)
-                                       if str_has_ws_run(pre) is str_has_ws_run(post)
-                                               need_text_cleanup = false
-                               @remove_character(@cursor.n, @cursor.i - 1)
-                               # call text_cleanup if whe created/removed a whitespace run
-                               if need_text_cleanup
-                                       @text_cleanup @cursor.n
-                               new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i - 1
-               # mark document changed and move the cursor
-               if changed?
-                       @changed()
-               if new_cursor?
-                       @move_cursor new_cursor
-               else
-                       @kill_cursor()
-               return
-       on_page_up_key: (e) ->
-               if @wrap2.scrollTop is 0
-                       return unless @cursor?
-                       new_cursor = first_cursor_position @tree
-                       if new_cursor?
-                               if new_cursor.n isnt @cursor.n or new_cursor.i isnt @cursor.i
-                                       @move_cursor new_cursor
-                       return
-               if @cursor?
-                       screen_y = @cursor.y - @wrap2.scrollTop
-               scroll_amount = @wrap2_height - breathing_room
-               @wrap2.scrollTop = Math.max 0, @wrap2.scrollTop - scroll_amount
-               if @cursor?
-                       @move_cursor_into_view screen_y + @wrap2.scrollTop
-       on_page_down_key: (e) ->
-               lowest_scrollpos = @wrap2.scrollHeight - @wrap2_height
-               if @wrap2.scrollTop is lowest_scrollpos
-                       return unless @cursor?
-                       new_cursor = last_cursor_position @tree
-                       if new_cursor?
-                               if new_cursor.n isnt @cursor.n or new_cursor.i isnt @cursor.i
-                                       @move_cursor new_cursor
-                       return
-               if @cursor?
-                       screen_y = @cursor.y - @wrap2.scrollTop
-               scroll_amount = @wrap2_height - breathing_room
-               @wrap2.scrollTop = Math.min lowest_scrollpos, @wrap2.scrollTop + scroll_amount
-               if @cursor?
-                       @move_cursor_into_view screen_y + @wrap2.scrollTop
-               return
-       move_cursor_into_view: (y_target) ->
-               return if y_target is @cursor.y
-               was = @cursor
-               y_min = @wrap2.scrollTop
-               unless @wrap2.scrollTop is 0
-                       y_min += breathing_room
-               y_max = @wrap2.scrollTop + @wrap2_height
-               unless @wrap2.scrollTop is @wrap2.scrollHeight - @wrap2_height # downmost
-                       y_max -= breathing_room
-               y_target = Math.min y_target, y_max
-               y_target = Math.max y_target, y_min
-               if y_target < @cursor.y
-                       finder = find_up_cursor_position
-                       far_enough = (cur, target_y) ->
-                               return cur.y + cur.h <= target_y
-               else
-                       finder = find_down_cursor_position
-                       far_enough = (cur, y_target) ->
-                               return cur.y >= y_target
-               loop
-                       cur = finder @tree, was, @cursor_ideal_x
-                       break unless cur?
-                       break if far_enough cur, y_target
-                       was = cur
-               if was is @cursor
-                       was = null
-               if was?
-                       if was.y + was.h > y_max
-                               was = null
-                       else if was.y < y_min
-                               was = null
-               if cur?
-                       if cur.y + cur.h > y_max
-                               cur = null
-                       else if cur.y < y_min
-                               cur = null
-               if cur? and was?
-                       # both valid, pick best
-                       if cur.y < y_min
-                               new_cursor = was
-                       else if was.y + was.h > y_max
-                               new_cursor = cur
-                       else if cur.y - y_target < y_target - was.y
-                               new_cursor = cur
-                       else
-                               new_cursor = was
-               else
-                       new_cursor = was ? cur
-               if new_cursor?
-                       saved_ideal_x = @cursor_ideal_x
-                       @move_cursor new_cursor
-                       @cursor_ideal_x = saved_ideal_x
-               return
-       clear_dom: -> # remove all the editable content (and cursor, overlays, etc)
-               while @idoc.body.childNodes.length
-                       @idoc.body.removeChild @idoc.body.childNodes[0]
-               @kill_cursor()
-               return
-       load_html: (html) ->
-               @tree = peach_parser.parse html, @parser_opts
-               if !@tree[0]?.parent
-                       @tree = peach_parser.parse '<p style="white-space: pre-wrap"> </p>', @parser_opts
-               @tree_parent = @tree[0]?.parent
-               @tree_parent.el = @idoc.body
-               @clear_dom()
-               instantiate_tree @tree, @tree_parent.el
-               @collapse_whitespace @tree
-               @changed()
-       changed: ->
-               @in_el.onchange = null
-               @in_el.value = @pretty_html @tree
-               @in_el.onchange = =>
-                       @load_html @in_el.value
-               @adjust_iframe_height()
-       adjust_iframe_height: ->
-               s = @wrap2.scrollTop
-               # when the content gets shorter, the idoc's body tag will continue to
-               # report the old (too big) height in Chrome. The workaround is to
-               # shrink the iframe before the content height:
-               @iframe.style.height = "10px"
-               h = parseInt(@idoc.body.scrollHeight, 10)
-               @iframe.style.height = "#{h}px"
-               @wrap2.scrollTop = s
-       # true if n is text node with only one caracter, and the only child of a tag
-       is_only_char_in_tag: (n, i) ->
-               return false unless n.type is 'text'
-               return false unless n.text.length is 1
-               return false if n.parent is @tree_parent
-               return false unless n.parent.children.length is 1
-               return true
-       # true if n is text node with just a space in it, and the only child of a tag
-       is_lone_space: (n, i) ->
-               return false unless n.type is 'text'
-               return false unless n.text is ' '
-               return false if n.parent is @tree_parent
-               return false unless n.parent.children.length is 1
-               return true
-       # detect special case: typing before a space that's the only thing in a block/doc
-       # reason: enter key creates blocks with just a space in them
-       insert_should_replace: (n, i) ->
-               return false unless i is 0
-               return false unless n.text is ' '
-               return true if n.parent is @tree_parent
-               if n.parent.children.length is 1
-                       if n.parent.children[0] is n
-                               # n is only child
-                               return true
-               return false
-       # after calling this, you MUST call changed() and text_cleanup()
-       insert_character: (n, i, char) ->
-               return if n.parent is @tree_parent # FIXME implement text nodes at top level
-               # insert the character
-               if @insert_should_replace n, i
-                       n.text = char
-               else if i is 0
-                       n.text = char + n.text
-               else if i is n.text.length
-                       # replace the space
-                       n.text += char
-               else
-                       n.text =
-                               n.text.substr(0, i) +
-                               char +
-                               n.text.substr(i)
-               n.el.nodeValue = n.text
-       # WARNING: after calling this, you MUST call changed() and text_cleanup()
-       remove_character: (n, i) ->
-               n.text = n.text.substr(0, i) + n.text.substr(i + 1)
-               n.el.nodeValue = n.text
-       computed_style: (n, prop) ->
-               if n.type is 'text'
-                       n = n.parent
-               style = @iframe.contentWindow.getComputedStyle n.el, null
-               return style.getPropertyValue prop
-       # returns the new white-space value that will preserve spaces for node n
-       preserve_space: (n, ideal_target) ->
-               if n.type is 'text'
-                       target = n.parent
-               else
-                       target = n
-               while target isnt ideal_target and not target.el.style.whiteSpace
-                       unless target?
-                               console.log "bug #967123"
-                               return
-                       target = target.parent
-               ws = ws_props[target.el.style.whiteSpace]?.to_preserve
-               ws ?= 'pre-wrap'
-               target.el.style.whiteSpace = ws
-               @update_style_from_el target
-               return ws
-       update_style_from_el: (n) ->
-               style = n.el.getAttribute 'style'
-               if style?
-                       n.attrs.style = style
-               else
-                       if n.attrs.style?
-                               delete n.attrs.style
-       # remove whitespace that would be trimmed
-       # replace whitespace that would collapse with a single space
-       # FIXME remove whitespace from after <br> (but not before)
-       # FIXME rewrite to
-       #     check computed white-space prop on txt parents
-       #     batch replace txt node contents (ie don't loop for each char)
-       collapse_whitespace: (tree = @tree) ->
-               prev = cur = next = null
-               prev_i = cur_i = next_i = 0
-               prev_pos = pos = next_pos = null
-               prev_px = cur_px = next_px = null
-               first = true
-               removed_char = null
-
-               tree_remove_empty_text_nodes(tree)
-
-               iterate = (tree, cb) ->
-                       for n in tree
-                               if n.type is 'text'
-                                       i = 0
-                                       while i < n.text.length # don't foreach, cb might remove chars
-                                               advance = cb n, i
-                                               if advance
-                                                       i += 1
-                               if n.type is 'tag'
-                                       block = is_display_block n.el
-                                       if block
-                                               cb null
-                                       if n.children.length > 0
-                                               iterate n.children, cb
-                                       if block
-                                               cb null
-               # remove cur char
-               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
-               replace_with_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)
-                               if removed_char isnt ' '
-                                       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, replace_with_space]
-                       # check for common case: single whitespace surrounded by non-whitespace chars
-                       if prev? and next?
-                               unless (is_space_code prev.text.charCodeAt prev_i) or (is_space_code next.text.charCodeAt next_i)
-                                       dbg = cur.text.charCodeAt cur_i
-                                       if cur.text.charAt(cur_i) is ' ' # perens required
-                                               # single space can't collapse, doesn't need fixin'
-                                               return false
-                                       else
-                                               # tab, newline, etc, can't collapse, but maybe should be replaced
-                                               fixers = [replace_with_space]
-                       bounds = text_range_bounds cur.el, cur_i, cur_i + 1
-                       # consistent cases:
-                       # 1. zero rects returned by getClientRects() means collapsed space
-                       if bounds is null
-                               return remove()
-                       # 2. width greater than zero means visible space
-                       if bounds.w > 0
-                               # has bounds, don't try removing
-                               fixers = [replace_with_space]
-                       # now the weird edge cases...
-                       #
-                       # firefox and chromium both report zero width for characters at the end
-                       # of a line where the text wraps (automatically, due to word-wrap) to
-                       # the next line. These do not appear to be distinguishable from
-                       # collapsed spaces via the range/bounds api, so...
-                       #
-                       # remove it from the dom, and if prev or next moves, put it back.
-                       #
-                       # this block (try changing it, put it back if something moves) is also
-                       # used on collapsable whitespace characters besides space. In this case
-                       # the character is replaced with a normal space character instead of
-                       # removed
-                       if prev? and not prev_px?
-                               prev_px = new_cursor_position n: prev, i: prev_i
-                       if next? and not next_px?
-                               next_px = new_cursor_position n: next, i: next_i
-                       #if prev is null and next is null
-                       #       parent_px = cur.parent.el.getBoundingClientRect()
-                       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
-                       next_i = i
-                       next_px = null
-                       advance = true
-                       if cur?
-                               removed = operate()
-                               # don't advance (to the next character next time) if we removed a
-                               # character from the same text node as ``next``, because doing so
-                               # renumbers the indexes in that string
-                               if removed and cur is next
-                                       advance = false
-                       else
-                               removed = false
-                       unless removed
-                               prev = cur
-                               prev_i = cur_i
-                               prev_px = cur_px
-                       cur = next
-                       cur_i = next_i
-                       cur_px = next_px
-                       return advance
-               queue null
-               iterate tree, queue
-               queue null
-
-               tree_remove_empty_text_nodes(tree)
-               return
-       # call this after you insert or remove inline nodes. It will:
-       #    merge consecutive text nodes
-       #    remove empty text nodes
-       #    adjust white-space property
-       # note: this assumes that all whitespace in text nodes should be displayed
-       # (ie not collapse or be trimmed) and will change the white-space property
-       # as needed to achieve this.
-       text_cleanup: (n) ->
-               if @is_display_block n
-                       block = n
-               else
-                       block = @find_block_parent n
-                       return unless block?
-               run = @get_text_run block
-               return unless run?
-               # merge consecutive text nodes
-               if run.length > 1
-                       i = 1
-                       prev = run[0]
-                       while i < run.length
-                               n = run[i]
-                               if prev.type is 'text' and n.type is 'text'
-                                       if prev.parent is n.parent
-                                               prev_i = n.parent.children.indexOf prev
-                                               n_i =    n.parent.children.indexOf n
-                                               if n_i is prev_i + 1
-                                                       prev.text = prev.text + n.text
-                                                       prev.el.textContent = prev.text
-                                                       @remove_node n
-                                                       run.splice i, 1
-                                                       continue # don't increment i or change prev
-                               i += 1
-                               prev = n
-               # remove empty text nodes
-               i = 0
-               while i < run.length
-                       n = run[i]
-                       if n.type is 'text'
-                               if n.text is ''
-                                       @remove_node n
-                                       # FIXME maybe remove parents recursively if this makes them empty
-                                       run.splice i, 1
-                                       continue # don't increment i
-                       i += 1
-               # note: inline tags can have white-space:pre-line/etc
-               # note: inline-blocks have their whitespace collapsed independantly of outer run
-               # note: inline-blocks are treated like non-whitespace char even if empty
-               if block.el.style.whiteSpace?
-                       ws = block.el.style.whiteSpace
-                       if ws_props[ws]
-                               if ws_props[ws].space
-                                       if ws_props[ws].to_collapse is 'normal'
-                                               block.el.style.whiteSpace = null
-                                       else
-                                               block.el.style.whiteSpace = ws_props[ws].to_collapse
-                                       @update_style_from_el block
-               # note: space after <br> colapses, but not space before
-               # check for spaces that would collapse without help
-               eats_start_sp = true # if the next node starts with space it collapses (unless pre)
-               prev = null
-               for n in run
-                       if n.type is 'tag'
-                               if n.name is 'br'
-                                       eats_start_sp = true
-                               else
-                                       eats_start_sp = false
-                       else # TEXT
-                               need_preserve = false
-                               if n.type isnt 'text'
-                                       console.log "bug #232308"
-                                       return
-                               if eats_start_sp
-                                       if is_space_code n.text.charCodeAt 0
-                                               need_preserve = true
-                               unless need_preserve
-                                       need_preserve = multi_sp_regex.test n.text
-                               if need_preserve
-                                       # do we have it already?
-                                       ws = @computed_style n, 'white-space' # FIXME implement this
-                                       unless ws_props[ws]?.space
-                                               # 2nd arg is ideal target for css rule
-                                               ws = @preserve_space n, block
-                                       eats_start_sp = false
-                               else
-                                       if is_space_code n.text.charCodeAt(n.text.length - 1)
-                                               ws = @computed_style n, 'white-space' # FIXME implement this
-                                               if ws_props[ws]?.space
-                                                       eats_start_sp = false
-                                               else
-                                                       eats_start_sp = true
-                                       else
-                                               eats_start_sp = false
-               # check if text ends with a collapsable space
-               if run.length > 0
-                       last = run[run.length - 1]
-                       if last.type is 'text'
-                               if eats_start_sp
-                                       @preserve_space last, block
-               return
-       css_clear: (n, prop) ->
-               return unless n.attrs.style?
-               return if n.attrs.style is ''
-               css_delimiter_regex = new RegExp('\s*;\s*', 'g') # FIXME make this global
-               styles = n.attrs.style.trim().split css_delimiter
-               return unless styles.length > 0
-               if styles[styles.length - 1] is ''
-                       styles.pop()
-                       return unless styles.length > 0
-               i = 0
-               while i < styles.length
-                       if styles[i].substr(0, 12) is 'white-space:'
-                               styles.splice i, 1
-                       else
-                               i += 1
-               return
-       # WARNING: after calling this one or more times, you MUST:
-       #    if it's inline: call @text_cleanup
-       #    call @changed()
-       remove_node: (n) ->
-               i = n.parent.children.indexOf n
-               if i is -1
-                       throw "BUG #9187112313"
-               n.el.parentNode.removeChild n.el
-               n.parent.children.splice i, 1
-               return
-       # remove a node from the tree/dom, insert into new_parent before insert_before?end
-       # WARNING: after calling this one or more times, you MUST:
-       #    if it's inline: call @text_cleanup
-       #    call @changed()
-       move_node: (n, new_parent, insert_before = null) ->
-               i = n.parent.children.indexOf n
-               if i is -1
-                       throw "Error: tried to remove node, but it's not in it's parents list of children"
-                       return
-               if insert_before?
-                       before_i = new_parent.children.indexOf insert_before
-                       if i is -1
-                               throw "Error: tried to move a node to be before a non-existent node"
-                       insert_before = insert_before.el
-               @remove_node n
-               if insert_before?
-                       new_parent.el.insertBefore n.el, insert_before
-                       new_parent.children.splice before_i, 0, n
-               else
-                       new_parent.el.appendChild n.el, insert_before
-                       new_parent.children.push n
-               n.parent = new_parent
-               return
-       kill_cursor: -> # remove it, forget where it was
-               if @cursor_visible
-                       @cursor_el.parentNode.removeChild @cursor_el
-                       @cursor_visible = false
-               @cursor = null
-               @annotate null
-               return
-       move_cursor: (cursor) ->
-               @cursor_ideal_x = cursor.x
-               @cursor = cursor
-               unless @cursor_visible
-                       @cursor_el = domify @outer_idoc, div: id: 'cursor'
-                       @overlay.appendChild @cursor_el
-                       @cursor_visible = true
-               @cursor_el.style.left = "#{cursor.x + overlay_padding - 1}px"
-               if cursor.h < 5
-                       height = 12
-               else
-                       height = cursor.h
-               @cursor_el.style.top = "#{cursor.y + overlay_padding + Math.round(height * .07)}px"
-               @cursor_el.style.height = "#{Math.round height * 0.82}px"
-               @annotate cursor.n
-               @scroll_into_view cursor.y, height
-               return
-       scroll_into_view: (y, h = 0) ->
-               y += overlay_padding # convert units from @idoc to @wrap2
-               # very top of document
-               if y <= breathing_room
-                       @wrap2.scrollTop = 0
-                       return
-               # very bottom of document
-               if y + h >= @wrap2.scrollHeight - breathing_room
-                       @wrap2.scrollTop = @wrap2.scrollHeight - @wrap2_height
-                       return
-               # The most scrolled up (lowest value for scrollTop) that would be OK
-               upmost = y + h + breathing_room - @wrap2_height
-               upmost = Math.max(upmost, 0)
-               # the most scrolled down (highest value for scrollTop) that would be OK
-               downmost = y - breathing_room
-               downmost = Math.min(downmost, @wrap2.scrollHeight - @wrap2_height)
-               if upmost > downmost # means h is too big to fit
-                       # scroll so top is visible
-                       @wrap2.scrollTop = downmost
-                       return
-               if @wrap2.scrollTop < upmost
-                       @wrap2.scrollTop = upmost
-                       return
-               if @wrap2.scrollTop > downmost
-                       @wrap2.scrollTop = downmost
-                       return
-               return
-       annotate: (n) ->
-               while @matting.length > 0
-                       @overlay.removeChild @matting[0]
-                       @matting.shift()
-               return unless n?
-               prev_bounds = x: 0, y: 0, w: 0, h: 0
-               alpha = 0.1
-               while n?.el? and n isnt @tree_parent
-                       if n.type is 'text'
-                               n = n.parent
-                               continue
-                       bounds = get_el_bounds n.el
-                       return unless bounds?
-                       if bounds.x is prev_bounds.x and bounds.y is prev_bounds.y and bounds.w is prev_bounds.w and bounds.h is prev_bounds.h
-                               n = n.parent
-                               continue
-                       ann_box = domify @outer_idoc, div: class: 'ann_box', style: "left: #{bounds.x - 1 + overlay_padding}px; top: #{bounds.y - 2 + overlay_padding}px; width: #{bounds.w}px; height: #{bounds.h}px" # outline: 1000px solid rgba(0,153,255,#{alpha});
-                       @overlay.appendChild ann_box
-                       @matting.push ann_box
-                       ann_tag = domify @outer_idoc, div: class: 'ann_tag', style: "left: #{bounds.x + 1 + overlay_padding}px; top: #{bounds.y - 7 + overlay_padding}px", children: [domify @outer_idoc, text: " #{n.name} "]
-                       @overlay.appendChild ann_tag
-                       @matting.push ann_tag
-                       n = n.parent
-                       alpha *= 1.5
-       pretty_html: (tree, indent = '', parent_flags = pre_ish: false, block: true, want_nl: false) ->
-               ret = ''
-               want_nl = parent_flags.want_nl
-               prev_in_flow_is_text = false
-               prev_in_flow_is_block = false
-               for n, i in tree
-                       # figure out flags
-                       inner_flags = want_nl: true
-                       is_br = false
-                       switch n.type
-                               when 'tag'
-                                       if n.name is 'br'
-                                               is_br = true
-                                       is_text = false
-                                       if n.el.currentStyle?
-                                               cs = n.el.currentStyle
-                                               whitespace = cs['white-space']
-                                               display = cs['display']
-                                               position = cs['position']
-                                               float = cs['float']
-                                               visibility = cs['visibility']
-                                       else
-                                               cs = @iframe.contentWindow.getComputedStyle(n.el, null)
-                                               whitespace = cs.getPropertyValue 'white-space'
-                                               display = cs.getPropertyValue 'display'
-                                               position = cs.getPropertyValue 'position'
-                                               float = cs.getPropertyValue 'float'
-                                               visibility = cs.getPropertyValue 'visibility'
-                                       if n.name is 'textarea'
-                                               inner_flags.pre_ish = true
-                                       else
-                                               inner_flags.pre_ish = whitespace.substr(0, 3) is 'pre'
-                                       switch float
-                                               when 'left', 'right'
-                                                       in_flow = false
-                                               else
-                                                       switch position
-                                                               when 'absolute', 'fixed'
-                                                                       in_flow = false
-                                                               else
-                                                                       if 'display' is 'none'
-                                                                               in_flow = false
-                                                                       else
-                                                                               switch visibility
-                                                                                       when 'hidden', 'collapse'
-                                                                                               in_flow = false
-                                                                                       else # visible
-                                                                                               in_flow = true
-                                       switch display
-                                               when 'inline', 'none'
-                                                       inner_flags.block = false
-                                                       is_block = in_flow_block = false
-                                               when 'inline-black'
-                                                       inner_flags.block = true
-                                                       is_block = in_flow_block = false
-                                               else # block, table, etc
-                                                       inner_flags.block = true
-                                                       is_block = true
-                                                       in_flow_block = in_flow
-                               when 'text'
-                                       is_text = true
-                                       is_block = false
-                                       in_flow = true
-                                       in_flow_block = false
-                               else # 'comment', 'doctype'
-                                       is_text = false
-                                       is_block = false
-                                       in_flow = false
-                                       in_flow_block = false
-                       # print whitespace if we can
-                       unless parent_flags.pre_ish
-                               unless prev_in_flow_is_text and is_br
-                                       if (i is 0 and parent_flags.block) or in_flow_block or prev_in_flow_is_block
-                                               if want_nl
-                                                       ret += "\n"
-                                               ret += indent
-                       switch n.type
-                               when 'tag'
-                                       ret += '<' + n.name
-                                       attr_keys = []
-                                       for k of n.attrs
-                                               attr_keys.unshift k
-                                       #attr_keys.sort()
-                                       for k in attr_keys
-                                               ret += " #{k}"
-                                               if n.attrs[k].length > 0
-                                                       ret += "=\"#{enc_attr n.attrs[k]}\""
-                                       ret += '>'
-                                       unless void_elements[n.name]?
-                                               if inner_flags.block
-                                                       next_indent = indent + '    '
-                                               else
-                                                       next_indent = indent
-                                               if n.children.length
-                                                       ret += @pretty_html n.children, next_indent, inner_flags
-                                               ret += "</#{n.name}>"
-                               when 'text'
-                                       ret += enc_text n.text
-                               when 'comment'
-                                       ret += "<!--#{n.text}-->" # TODO encode?
-                               when 'doctype'
-                                       ret += "<!DOCTYPE #{n.name}"
-                                       if n.public_identifier? and n.public_identifier.length > 0
-                                               ret += " \"#{n.public_identifier}\""
-                                       if n.system_identifier? and n.system_identifier.length > 0
-                                               ret += " \"#{n.system_identifier}\""
-                                       ret += ">"
-                       want_nl = true
-                       if in_flow
-                               prev_in_flow_is_text = is_text
-                               prev_in_flow_is_block = is_block or (in_flow and is_br)
-               if tree.length
-                       # output final newline if allowed
-                       unless parent_flags.pre_ish
-                               if prev_in_flow_is_block or parent_flags.block
-                                       ret += "\n#{indent.substr 4}"
-               return ret
-       onblur: ->
-               @kill_cursor()
-       have_focus: ->
-               @editor_is_focused = true
-               @poll_for_blur()
-       poll_for_blur: ->
-               return if @poll_for_blur_timeout? # already polling
-               @poll_for_blur_timeout = timeout 150, =>
-                       next_frame => # pause polling when browser knows we're not active/visible/etc.
-                               @poll_for_blur_timeout = null
-                               if document.activeElement is @outer_iframe
-                                       @poll_for_blur()
-                               else
-                                       @editor_is_focused = false
-                                       @onblur()
-
-window.peach_html5_editor = (args...) ->
-       return new PeachHTML5Editor args...
-
-# test in browser: peach_html5_editor(document.getElementsByTagName('textarea')[0])
diff --git a/editor.js b/editor.js
new file mode 100644 (file)
index 0000000..16986f4
--- /dev/null
+++ b/editor.js
@@ -0,0 +1,2490 @@
+// Copyright 2015 Jason Woofenden
+// This file implements an WYSIWYG editor in the browser (no contenteditable)
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU Affero General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option) any
+// later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
+// details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+(function() {
+var KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_END, KEY_ENTER, KEY_ESCAPE, KEY_HOME, KEY_INSERT, KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, KEY_TAB, KEY_UP, breathing_room, control_key_codes, enc_attr_regex, enc_text_regex, event_return, find_prev_cursor_position, find_up_cursor_position, get_el_bounds, ignore_key_codes, js_attr_regex, multi_sp_regex, no_text_elements, overlay_padding, text_range_bounds, valid_attr_regex, void_elements, ws_props, xy_to_cursor, slice = [].slice
+
+// SETTINGS
+overlay_padding = 10
+breathing_room = 30 // minimum pixels above/below cursor (scrolling)
+
+function timeout (ms, cb) {
+       return setTimeout(cb, ms)
+}
+
+function next_frame (cb) {
+       if (window.requestAnimationFrame != null) {
+               window.requestAnimationFrame(cb)
+       } else {
+               timeout(16, cb)
+       }
+}
+
+function this_url_sans_path () {
+       var clip, ret
+       ret = "" + window.location.href
+       clip = ret.lastIndexOf('#')
+       if (clip > -1) {
+               ret = ret.substr(0, clip)
+       }
+       clip = ret.lastIndexOf('?')
+       if (clip > -1) {
+               ret = ret.substr(0, clip)
+       }
+       clip = ret.lastIndexOf('/')
+       if (clip > -1) {
+               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,
+               to_collapse: 'nowrap'
+       },
+       'pre-wrap': {
+               space: true,
+               newline: true,
+               wrap: true,
+               to_collapse: 'normal'
+       }
+}
+
+// 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"
+js_attr_regex = new RegExp('^[oO][nN].')
+// html5 spec says that only these characters are collapsable
+multi_sp_regex = new RegExp('[\u0020\u0009\u000a\u000c\u000d][\u0020\u0009\u000a\u000c\u000d]')
+
+function str_has_ws_run (str) {
+       return multi_sp_regex.test(str)
+}
+
+// text nodes don't have getBoundingClientRect(), so use selection api to find
+// it.
+get_el_bounds = window.bounds = function(el) {
+       var doc, range, rect, win, x_fix, y_fix
+       if (el.getBoundingClientRect != null) {
+               rect = el.getBoundingClientRect()
+       } else {
+               // text nodes don't have getBoundingClientRect(), so use range api
+               range = el.ownerDocument.createRange()
+               range.selectNodeContents(el)
+               rect = range.getBoundingClientRect()
+       }
+       doc = el.ownerDocument.documentElement
+       win = el.ownerDocument.defaultView
+       y_fix = win.pageYOffset - doc.clientTop
+       x_fix = win.pageXOffset - doc.clientLeft
+       return {
+               x: rect.left + x_fix,
+               y: rect.top + y_fix,
+               w: rect.width != null ? rect.width : rect.right - rect.left,
+               h: rect.height != null ? rect.height : rect.top - rect.bottom
+       }
+}
+
+function is_display_block (el) {
+       if (el.currentStyle != null) {
+               return el.currentStyle.display === 'block'
+       } else {
+               return window.getComputedStyle(el, null).getPropertyValue('display') === 'block'
+       }
+}
+
+// Pass return value from dom event handlers to this.
+// If they return false, this will addinionally stop propagation and default.
+function event_return (e, bool) {
+       if (bool === false) {
+               if (e.stopPropagation != null) {
+                       e.stopPropagation()
+               }
+               if (e.preventDefault != null) {
+                       e.preventDefault()
+               }
+       }
+       return bool
+}
+
+// Warning: currently assumes you're asking about a single character
+// Note: chromium returns multiple bounding rects for a space at a line-break
+// Note: chromium's getBoundingClientRect() is broken (when zero-area client rects)
+// Note: sometimes returns null (eg for whitespace that is not visible)
+text_range_bounds = function(el, start, end) {
+       var doc, range, rect, rects, win, x_fix, y_fix
+       range = document.createRange()
+       range.setStart(el, start)
+       range.setEnd(el, end)
+       rects = range.getClientRects()
+       if (rects.length > 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
+       win = el.ownerDocument.defaultView
+       y_fix = win.pageYOffset - doc.clientTop
+       x_fix = win.pageXOffset - doc.clientLeft
+       return {
+               x: rect.left + x_fix,
+               y: rect.top + y_fix,
+               w: rect.width != null ? rect.width : rect.right - rect.left,
+               h: rect.height != null ? rect.height : rect.top - rect.bottom,
+               rects: rects,
+               bounding: range.getBoundingClientRect()
+       }
+}
+
+function CursorPosition(args) {
+       this.n = args.n != null ? args.n : null
+       this.i = args.i != null ? args.i : null
+       if (args.x != null) {
+               this.x = args.x
+               this.y = args.y
+               this.h = args.h
+       } else {
+               this.set_xyh()
+       }
+}
+
+CursorPosition.prototype.set_xyh = function() {
+       var range, ret
+       range = document.createRange()
+       if (this.n.text.length === 0) {
+               ret = text_range_bounds(this.n.el, 0, 0)
+       } else if (this.i === this.n.text.length) {
+               ret = text_range_bounds(this.n.el, this.i - 1, this.i)
+               if (ret != null) {
+                       ret.x += ret.w
+               }
+       } else {
+               ret = text_range_bounds(this.n.el, this.i, this.i + 1)
+       }
+       if (ret != null) {
+               this.x = ret.x
+               this.y = ret.y
+               this.h = ret.h
+       } else {
+               this.x = null
+               this.y = null
+               this.h = null
+       }
+       return ret
+}
+
+function new_cursor_position (args) {
+       var ret
+       ret = new CursorPosition(args)
+       if (ret.x != null) {
+               return ret
+       }
+       return null
+}
+
+// encode text so it can be safely placed inside an html attribute
+enc_attr_regex = new RegExp('(&)|(")|(\u00A0)', 'g')
+function enc_attr (txt) {
+       return txt.replace(enc_attr_regex, function(match, amp, quote) {
+               if (amp) {
+                       return '&amp;'
+               }
+               if (quote) {
+                       return '&quot;'
+               }
+               return '&nbsp;'
+       })
+}
+enc_text_regex = new RegExp('(&)|(<)|(\u00A0)', 'g')
+function enc_text (txt) {
+       return txt.replace(enc_text_regex, function(match, amp, lt) {
+               if (amp) {
+                       return '&amp;'
+               }
+               if (lt) {
+                       return '&lt;'
+               }
+               return '&nbsp;'
+       })
+}
+
+void_elements = {
+       area: true,
+       base: true,
+       br: true,
+       col: true,
+       embed: true,
+       hr: true,
+       img: true,
+       input: true,
+       keygen: true,
+       link: true,
+       meta: true,
+       param: true,
+       source: true,
+       track: true,
+       wbr: true
+}
+// TODO make these always pretty-print (on the inside) like blocks
+// TODO careful though: whitespace might get pushed to parent, which might be rendered
+no_text_elements = { // these elements never contain text
+       select: true,
+       table: true,
+       tr: true,
+       thead: true,
+       tbody: true,
+       ul: true,
+       ol: true
+}
+
+function domify (doc, hash) {
+       var attrs, el, i, k, tag, v
+       for (tag in hash) {
+               attrs = hash[tag]
+               if (tag === 'text') {
+                       return document.createTextNode(attrs)
+               }
+               el = document.createElement(tag)
+               for (k in attrs) {
+                       v = attrs[k]
+                       if (k === 'children') {
+                               for (i = 0; i < v.length; i++) {
+                                       el.appendChild(v[i])
+                               }
+                       } else {
+                               el.setAttribute(k, v)
+                       }
+               }
+       }
+       return el
+}
+
+ignore_key_codes = {
+       '18': true,  // alt
+       '20': true,  // capslock
+       '17': true,  // ctrl
+       '144': true, // numlock
+       '16': true,  // shift
+       '91': true   // windows "start" key
+}
+// key codes: (valid on keydown, not keypress)
+KEY_LEFT = 37
+KEY_UP = 38
+KEY_RIGHT = 39
+KEY_DOWN = 40
+KEY_BACKSPACE = 8 // <--
+KEY_DELETE = 46 // -->
+KEY_END = 35
+KEY_ENTER = 13
+KEY_ESCAPE = 27
+KEY_HOME = 36
+KEY_INSERT = 45
+KEY_PAGE_UP = 33
+KEY_PAGE_DOWN = 34
+KEY_TAB = 9
+control_key_codes = { // we react to these, but they aren't typing
+       '37': KEY_LEFT,
+       '38': KEY_UP,
+       '39': KEY_RIGHT,
+       '40': KEY_DOWN,
+       '35': KEY_END,
+        '8': KEY_BACKSPACE,
+       '46': KEY_DELETE,
+       '13': KEY_ENTER,
+       '27': KEY_ESCAPE,
+       '36': KEY_HOME,
+       '45': KEY_INSERT,
+       '33': KEY_PAGE_UP,
+       '34': KEY_PAGE_DOWN,
+        '9': KEY_TAB
+}
+
+function instantiate_tree (tree, parent) {
+       var c, i, k, remove, results, v
+       remove = []
+       for (i = 0; i < tree.length; ++i) {
+               c = tree[i]
+               switch (c.type) {
+                       case 'text':
+                               c.el = parent.ownerDocument.createTextNode(c.text)
+                               parent.appendChild(c.el)
+                       break
+                       case 'tag':
+                               if (c.name === 'script' || c.name === 'object' || c.name === 'iframe' || c.name === 'link') {
+                                       // TODO put placeholders instead
+                                       remove.unshift(i)
+                                       continue
+                               }
+                               // TODO create in correct namespace
+                               c.el = parent.ownerDocument.createElement(c.name)
+                               ref1 = c.attrs
+                               for (k in ref1) {
+                                       v = ref1[k]
+                                       // FIXME if attr_whitelist[k]?
+                                       if (valid_attr_regex.test(k)) {
+                                               if (!js_attr_regex.test(k)) {
+                                                       c.el.setAttribute(k, v)
+                                               }
+                                       }
+                               }
+                               parent.appendChild(c.el)
+                               if (c.children.length) {
+                                       instantiate_tree(c.children, c.el)
+                               }
+               }
+       }
+       results = []
+       for (i = 0; i < remove.length; i++) {
+               // FIXME this deletes the wrong node when siblings are removed
+               index = remove[i]
+               results.push(tree.splice(index, 1))
+       }
+       return results
+}
+
+function traverse_tree (tree, cb) {
+       var c, done, i
+       done = false
+       for (i = 0; i < tree.length; i++) {
+               c = tree[i]
+               done = cb(c)
+               if (done) {
+                       return done
+               }
+               if (c.children.length) {
+                       done = traverse_tree(c.children, cb)
+                       if (done) {
+                               return done
+                       }
+               }
+       }
+       return done
+}
+
+function first_cursor_position (tree) {
+       var found
+       found = null
+       traverse_tree(tree, function(node, state) {
+               var cursor
+               if (node.type === 'text') {
+                       cursor = new_cursor_position({n: node, i: 0})
+                       if (cursor != null) {
+                               found = cursor
+                               return true // done traversing
+                       }
+               }
+               return false // not done traversing
+       })
+       return found // maybe null
+}
+
+// this will fail when text has non-locatable cursor positions (eg collapsed whitespace)
+function find_next_cursor_position (tree, cursor) {
+       var found, new_cursor, state_before
+       if (cursor.n.type === 'text' && cursor.n.text.length > cursor.i) {
+               new_cursor = new_cursor_position({n: cursor.n, i: cursor.i + 1})
+               if (new_cursor != null) {
+                       return new_cursor
+               }
+       }
+       state_before = true
+       found = null
+       traverse_tree(tree, function(node, state) {
+               if (node.type === 'text' && state_before === false) {
+                       new_cursor = new_cursor_position({n: node, i: 0})
+                       if (new_cursor != null) {
+                               found = new_cursor
+                               return true // done traversing
+                       }
+               }
+               if (node === cursor.n) {
+                       state_before = false
+               }
+               return false // not done traversing
+       })
+       if (found != null) {
+               return found
+       }
+       return null
+}
+
+function last_cursor_position (tree) {
+       var found
+       found = null
+       traverse_tree(tree, function(node) {
+               var cursor
+               if (node.type === 'text') {
+                       cursor = new_cursor_position({n: node, i: node.text.length})
+                       if (cursor != null) {
+                               found = cursor
+                       }
+               }
+               return false // not done traversing
+       })
+       return found // maybe null
+}
+
+// this will fail when text has non-locatable cursor positions (eg collapsed whitespace)
+function find_prev_cursor_position (tree, cursor) {
+       var found, found_prev, new_cursor
+       if (cursor.n.type === 'text' && cursor.i > 0) {
+               new_cursor = new_cursor_position({n: cursor.n, i: cursor.i - 1})
+               if (new_cursor != null) {
+                       return new_cursor
+               }
+       }
+       found_prev = null
+       found = null
+       traverse_tree(tree, function(node) {
+               if (node === cursor.n) {
+                       found = found_prev // maybe null
+                       return true // done traversing
+               }
+               if (node.type === 'text') {
+                       new_cursor = new_cursor_position({n: node, i: node.text.length})
+                       if (new_cursor != null) {
+                               found_prev = new_cursor
+                       }
+               }
+               return false // not done traversing
+       })
+       return found // maybe null
+}
+
+function find_up_cursor_position (tree, cursor, ideal_x) {
+       var new_cursor, prev_cursor, target_y
+       new_cursor = cursor
+       // go prev until we're higher on y axis
+       while (new_cursor.y >= cursor.y) {
+               new_cursor = find_prev_cursor_position(tree, new_cursor)
+               if (new_cursor == null) {
+                       return null
+               }
+       }
+       // done early if we're already left of old cursor position
+       if (new_cursor.x <= ideal_x) {
+               return new_cursor
+       }
+       target_y = new_cursor.y
+       // search leftward, until we find the closest position
+       // new_cursor is the prev-most position we've checked
+       // prev_cursor is the older value, so it's not as prev as new_cursor
+       while (new_cursor.x > ideal_x && new_cursor.y === target_y) {
+               prev_cursor = new_cursor
+               new_cursor = find_prev_cursor_position(tree, new_cursor)
+               if (new_cursor == null) {
+                       break
+               }
+       }
+       // move cursor to prev_cursor or new_cursor
+       if (new_cursor != null) {
+               if (new_cursor.y === target_y) {
+                       // both valid, and on the same line, use closest
+                       if ((ideal_x - new_cursor.x) < (prev_cursor.x - ideal_x)) {
+                               return new_cursor
+                       } else {
+                               return prev_cursor
+                       }
+               } else {
+                       // new_cursor on wrong line, use prev_cursor
+                       return prev_cursor
+               }
+       } else {
+               // can't go any further prev, use prev_cursor
+               return prev_cursor
+       }
+}
+
+function find_down_cursor_position (tree, cursor, ideal_x) {
+       var new_cursor, prev_cursor, target_y
+       new_cursor = cursor
+       // go next until we move on the y axis
+       while (new_cursor.y <= cursor.y) {
+               new_cursor = find_next_cursor_position(tree, new_cursor)
+               if (new_cursor == null) {
+                       return null
+               }
+       }
+       // done early if we're already right of old cursor position
+       if (new_cursor.x >= ideal_x) {
+               // this would be strange, but could happen due to runaround
+               return new_cursor
+       }
+       target_y = new_cursor.y
+       // search rightward, until we find the closest position
+       // new_cursor is the next-most position we've checked
+       // prev_cursor is the older value, so it's not as next as new_cursor
+       while (new_cursor.x < ideal_x && new_cursor.y === target_y) {
+               prev_cursor = new_cursor
+               new_cursor = find_next_cursor_position(tree, new_cursor)
+               if (new_cursor == null) {
+                       break
+               }
+       }
+       if (new_cursor != null) {
+               if (new_cursor.y === target_y) {
+                       // both valid, and on the same line, use closest
+                       if ((new_cursor.x - ideal_x) < (ideal_x - prev_cursor.x)) {
+                               return new_cursor
+                       } else {
+                               return prev_cursor
+                       }
+               } else {
+                       // new_cursor on wrong line, use prev_cursor
+                       return prev_cursor
+               }
+       } else {
+               // can't go any further prev, use prev_cursor
+               return prev_cursor
+       }
+}
+
+function xy_to_cursor (tree, xy) {
+       var after, before, bounds, cur, guess_i, i, n, ret
+       for (i = 0; i < tree.length; i++) {
+               n = tree[i]
+               if (n.type === 'tag' || n.type === 'text') {
+                       bounds = get_el_bounds(n.el)
+                       if (xy.x < bounds.x) {
+                               continue
+                       }
+                       if (xy.x > bounds.x + bounds.w) {
+                               continue
+                       }
+                       if (xy.y < bounds.y) {
+                               continue
+                       }
+                       if (xy.y > bounds.y + bounds.h) {
+                               continue
+                       }
+                       if (n.children.length) {
+                               ret = xy_to_cursor(n.children, xy)
+                               if (ret != null) {
+                                       return ret
+                               }
+                       }
+                       if (n.type === 'text') {
+                               // click is within bounding box that contains all text.
+                               if (n.text.length === 0) {
+                                       ret = new_cursor_position({n: n, i: 0})
+                                       if (ret != null) {
+                                               return ret
+                                       }
+                                       continue
+                               }
+                               before = new_cursor_position({n: n, i: 0})
+                               if (before == null) {
+                                       continue
+                               }
+                               after = new_cursor_position({n: n, i: n.text.length})
+                               if (after == null) {
+                                       continue
+                               }
+                               if (xy.y < before.y + before.h && xy.x < before.x) {
+                                       // console.log 'before first char on first line'
+                                       continue
+                               }
+                               if (xy.y > after.y && xy.x > after.x) {
+                                       // console.log 'after last char on last line'
+                                       continue
+                               }
+                               if (xy.y < before.y) {
+                                       console.log("Warning: click in text bounding box but above first line")
+                                       continue // above first line (runaround?)
+                               }
+                               if (xy.y > after.y + after.h) {
+                                       console.log("Warning: click in text bounding box but below last line", xy.y, after.y, after.h)
+                                       continue // below last line (shouldn't happen?)
+                               }
+                               while (after.i - before.i > 1) {
+                                       guess_i = Math.round((before.i + after.i) / 2)
+                                       cur = new_cursor_position({n: n, i: guess_i})
+                                       if (cur == null) {
+                                               console.log("error: failed to find cursor pixel location for", n, guess_i)
+                                               before = null
+                                               break
+                                       }
+                                       if (xy.y < cur.y || (xy.y <= cur.y + cur.h && xy.x < cur.x)) {
+                                               after = cur
+                                       } else {
+                                               before = cur
+                                       }
+                               }
+                               if (before == null) { // signals failure to find a cursor position
+                                       continue
+                               }
+                               // which one is closest?
+                               if (Math.abs(before.x - xy.x) < Math.abs(after.x - xy.x)) {
+                                       return before
+                               } else {
+                                       return after
+                               }
+                       }
+               }
+       }
+       return null
+}
+
+// browsers collapse these (html5 spec calls these "space characters")
+function is_space_code (char_code) {
+       switch (char_code) {
+               case 9:
+               case 10:
+               case 12:
+               case 13:
+               case 32:
+                       return true
+       }
+       return false
+}
+function is_space (chr) {
+       return is_space_code(chr.charCodeAt(0))
+}
+
+function tree_remove_empty_text_nodes (tree) {
+       var c, empties, i, j, n
+       empties = []
+       traverse_tree(tree, function(n) {
+               if (n.type === 'text') {
+                       if (n.text.length === 0) {
+                               empties.unshift(n)
+                       }
+               }
+               return false // not done traversing
+       })
+       for (i = 0; i < empties.length; i++) {
+               n = empties[i]
+               // don't completely empty the tree
+               if (tree.length === 1) {
+                       if (tree[0].type === 'text') {
+                               console.log("oop, leaving a blank node because it's the only thing")
+                               return
+                       }
+               }
+               n.el.parentNode.removeChild(n.el)
+               ref = n.parent.children
+               for (j = 0; j < ref.length; ++j) {
+                       c = ref[j]
+                       if (c === n) {
+                               n.parent.children.splice(j, 1)
+                               break
+                       }
+               }
+       }
+}
+
+function PeachHTML5Editor (in_el, options) {
+       // Options: (all optional)
+       //   editor_id: "id" attribute for outer-most element created by/for editor
+       //   css_file: filename of a css file to style editable content
+       //   on_init: callback for when the editable content is in place
+       var css, opt_fragment, outer_bounds, outer_iframe_style, outer_wrap
+       this.options = options != null ? options : {}
+       this.in_el = in_el
+       this.tree = null // array of Nodes, all editable content
+       this.tree_parent = null // this.tree is this.children. .el might === this.idoc.body
+       this.matting = []
+       this.init_1_called = false // when iframes have loaded
+       this.outer_iframe // iframe to hold editor
+       this.outer_idoc // "document" object for this.outer_iframe
+       this.wrap2 = null // scrollbar is on this
+       this.wrap2_offset = null
+       this.wrap2_height = null // including padding
+       this.iframe = null // iframe to hold editable content
+       this.idoc = null // "document" object for this.iframe
+       this.cursor = null
+       this.cursor_el = null
+       this.cursor_visible = false
+       this.cursor_ideal_x = null
+       this.poll_for_blur_timeout = null
+       opt_fragment = this.options.fragment != null ? this.options.fragment : true
+       this.parser_opts = {}
+       if (opt_fragment) {
+               this.parser_opts.fragment = 'body'
+       }
+       this.outer_iframe = domify(document, {iframe: {}})
+       outer_iframe_style = 'border: none !important; margin: 0 !important; padding: 0 !important; height: 100% !important; width: 100% !important;'
+       if (this.options.editor_id != null) {
+               this.outer_iframe.setAttribute('id', this.options.editor_id)
+       }
+       this.outer_iframe.onload = (function(_this) {
+               return function() {
+                       var icss
+                       _this.outer_idoc = _this.outer_iframe.contentDocument
+                       icss = domify(_this.outer_idoc, { style: { children: [
+                               domify(_this.outer_idoc, {text: css})
+                       ]}})
+                       _this.outer_idoc.head.appendChild(icss)
+                       _this.iframe = domify(_this.outer_idoc, {iframe: {sandbox: 'allow-same-origin allow-scripts'}})
+                       _this.iframe.onload = function() {
+                               return _this.init_1()
+                       }
+                       timeout(200, function() { // firefox never fires this onload
+                               if (!_this.init_1_called) {
+                                       return _this.init_1()
+                               }
+                       })
+                       _this.outer_idoc.body.appendChild(
+                               domify(_this.outer_idoc, {div: {id: 'wrap1', children: [
+                                       domify(_this.outer_idoc, {div: {
+                                               style: "position: absolute; top: 0; left: 1px; font-size: 10px",
+                                               children: [domify(_this.outer_idoc, {text: "Peach HTML5 Editor"})]
+                                       }}),
+                                       _this.wrap2 = domify(_this.outer_idoc, {div: {id: 'wrap2', children: [
+                                               domify(_this.outer_idoc, {div: {id: 'wrap3', children: [
+                                                       _this.iframe,
+                                                       _this.overlay = domify(_this.outer_idoc, { div: { id: 'overlay' }})
+                                               ]}})
+                                       ]}})
+                               ]}})
+                       )
+               }
+       })(this)
+       outer_wrap = domify(document, {div: {"class": 'peach_html5_editor' }})
+       this.in_el.parentNode.appendChild(outer_wrap)
+       outer_bounds = get_el_bounds(outer_wrap)
+       if (outer_bounds.w < 300) {
+               outer_bounds.w = 300
+       }
+       if (outer_bounds.h < 300) {
+               outer_bounds.h = 300
+       }
+       outer_iframe_style += "width: " + outer_bounds.w + "px; height: " + outer_bounds.h + "px;"
+       this.outer_iframe.setAttribute('style', outer_iframe_style)
+       css = this.generate_outer_css({w: outer_bounds.w, h: outer_bounds.h})
+       outer_wrap.appendChild(this.outer_iframe)
+}
+PeachHTML5Editor.prototype.init_1 = function() { // this.iframe has loaded (but not it's css)
+       var istyle
+       this.idoc = this.iframe.contentDocument
+       this.init_1_called = true
+       // chromium doesn't resolve relative urls as though they were at the same domain
+       // so add a <base> tag
+       this.idoc.head.appendChild(domify(this.idoc, {base: {href: this_url_sans_path()}}))
+       // don't let @iframe have scrollbars
+       this.idoc.head.appendChild(domify(this.idoc, {style: {children: [
+               domify(this.idoc, {text: "body { overflow: hidden; }"})
+       ]}}))
+       if (this.options.css_file) {
+               istyle = domify(this.idoc, {link: {rel: 'stylesheet', href: this.options.css_file}})
+               istyle.onload = (function(_this) {
+                       return function() {
+                               return _this.init_2()
+                       }
+               })(this)
+               this.idoc.head.appendChild(istyle)
+       } else {
+               this.init_2()
+       }
+}
+PeachHTML5Editor.prototype.init_2 = function() { // this.iframe and it's css file(s) are ready
+       this.overlay.onclick = (function(_this) {
+               return function(e) {
+                       _this.have_focus()
+                       return event_return(e, _this.onclick(e))
+               }
+       })(this)
+       this.overlay.ondoubleclick = (function(_this) {
+               return function(e) {
+                       _this.have_focus()
+                       return event_return(e, _this.ondoubleclick(e))
+               }
+       })(this)
+       this.outer_idoc.body.onkeyup = (function(_this) {
+               return function(e) {
+                       _this.have_focus()
+                       return event_return(e, _this.onkeyup(e))
+               }
+       })(this)
+       this.outer_idoc.body.onkeydown = (function(_this) {
+               return function(e) {
+                       _this.have_focus()
+                       return event_return(e, _this.onkeydown(e))
+               }
+       })(this)
+       this.outer_idoc.body.onkeypress = (function(_this) {
+               return function(e) {
+                       _this.have_focus()
+                       return event_return(e, _this.onkeypress(e))
+               }
+       })(this)
+       this.load_html(this.in_el.value)
+       if (this.options.on_init != null) {
+               return this.options.on_init()
+       }
+}
+PeachHTML5Editor.prototype.generate_outer_css = function(args) {
+       var frame_width, h, inner_padding, occupy, ret, w
+       w = args.w != null ? args.w : 300
+       h = args.h != null ? args.h : 300
+       inner_padding = args.inner_padding != null ? args.inner_padding : overlay_padding
+       frame_width = args.frame_width != null ? args.frame_width : inner_padding
+       occupy = function(left, top, right, bottom) {
+               if (top == null) {
+                       top = left
+               }
+               if (right == null) {
+                       right = left
+               }
+               if (bottom == null) {
+                       bottom = top
+               }
+               w -= left + right
+               h -= top + bottom
+               return Math.max(left, top, right, bottom)
+       }
+       ret = ''
+       ret += 'body {'
+       ret +=     'margin: 0;'
+       ret +=     'padding: 0;'
+       ret +=     'color: black;'
+       ret +=     'background: white;'
+       ret += '}'
+       ret += '#wrap1 {'
+       ret +=     "border: " + (occupy(1)) + "px solid black;"
+       ret +=     "padding: " + (occupy(frame_width)) + "px;"
+       ret += '}'
+       ret += '#wrap2 {'
+       ret +=     "border: " + (occupy(1)) + "px solid black;"
+       this.wrap2_height = h // including padding because padding scrolls
+       ret +=     "padding: " + (occupy(inner_padding)) + "px;"
+       ret +=     "padding-right: " + (inner_padding + occupy(0, 0, 15, 0)) + "px;"
+       ret +=     "width: " + w + "px;"
+       ret +=     "height: " + h + "px;"
+       ret += 'overflow-x: hidden;'
+       ret += 'overflow-y: scroll;'
+       ret += '}'
+       ret += '#wrap3 {'
+       ret += 'position: relative;'
+       ret +=     "width: " + w + "px;"
+       ret +=     "min-height: " + h + "px;"
+       ret += '}'
+       ret += 'iframe {'
+       ret += 'box-sizing: border-box;'
+       ret += 'margin: 0;'
+       ret += 'border: none;'
+       ret += 'padding: 0;'
+       ret +=     "width: " + w + "px;"
+       //ret +=     "height: " + h + "px;" // height auto-set when content set/changed
+       ret +=     '-ms-user-select: none;'
+       ret +=     '-webkit-user-select: none;'
+       ret +=     '-moz-user-select: none;'
+       ret +=     'user-select: none;'
+       ret += '}'
+       ret += '#overlay {'
+       ret +=     'position: absolute;'
+       ret +=     "left: -" + inner_padding + "px;"
+       ret +=     "top: -" + inner_padding + "px;"
+       ret +=     "right: -" + inner_padding + "px;"
+       ret +=     "bottom: -" + inner_padding + "px;"
+       ret +=     'overflow: hidden;'
+       ret += '}'
+       ret += '.lightbox {'
+       ret +=     'position: absolute;'
+       ret +=     'background: rgba(100,100,100,0.2);'
+       ret += '}'
+       ret += '#cursor {'
+       ret +=     'position: absolute;'
+       ret +=     'width: 2px;'
+       ret +=     'background: linear-gradient(0deg, rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1));'
+       ret +=     'background-size: 200% 200%;'
+       ret +=     '-webkit-animation: blink 1s linear normal infinite;'
+       ret +=     'animation: blink 1s linear normal infinite;'
+       ret += '}'
+       ret += '@-webkit-keyframes blink {'
+       ret +=     '0%{background-position:0% 0%}'
+       ret +=     '100%{background-position:0% -100%}'
+       ret += '}'
+       ret += '@keyframes blink { '
+       ret +=     '0%{background-position:0% 0%}'
+       ret +=     '100%{background-position:0% -100%}'
+       ret += '}'
+       ret += '.ann_box {'
+       ret +=     'z-index: 5;'
+       ret +=     'position: absolute;'
+       ret +=     'border: 1px solid rgba(0,0,0,0.1);'
+       ret +=     'outline: 1px solid rgba(255,255,255,0.1);' // in case there's a black background
+       ret += '}'
+       ret += '.ann_tag {'
+       ret +=     'z-index: 10;'
+       ret +=     'position: absolute;'
+       ret +=     'font-size: 8px;'
+       ret +=     'white-space: pre;'
+       ret +=     'background: rgba(255,255,255,0.4);'
+       ret +=     '-ms-user-select: none;'
+       ret +=     '-webkit-user-select: none;'
+       ret +=     '-moz-user-select: none;'
+       ret +=     'user-select: none;'
+       ret += '}'
+       return ret
+}
+PeachHTML5Editor.prototype.overlay_event_to_inner_xy = function(e) {
+       var x, y
+       if (this.wrap2_offset == null) {
+               this.wrap2_offset = get_el_bounds(this.wrap2)
+       }
+       x = e.pageX - overlay_padding
+       y = e.pageY - overlay_padding + this.wrap2.scrollTop
+       return {
+               x: x - this.wrap2_offset.x,
+               y: y - this.wrap2_offset.y
+       }
+}
+PeachHTML5Editor.prototype.onclick = function(e) {
+       var new_cursor, xy
+       xy = this.overlay_event_to_inner_xy(e)
+       new_cursor = xy_to_cursor(this.tree, xy)
+       if (new_cursor != null) {
+               this.move_cursor(new_cursor)
+       } else {
+               this.kill_cursor()
+       }
+       return false
+}
+PeachHTML5Editor.prototype.ondoubleclick = function(e) {
+       return false
+}
+PeachHTML5Editor.prototype.onkeyup = function(e) {
+       if (e.ctrlKey) {
+               return
+       }
+       if (ignore_key_codes[e.keyCode] != null) {
+               return false
+       }
+       //return false if control_key_codes[e.keyCode] != null
+}
+PeachHTML5Editor.prototype.onkeydown = function(e) {
+       var new_cursor, saved_ideal_x
+       if (e.ctrlKey) {
+               return
+       }
+       if (ignore_key_codes[e.keyCode] != null) {
+               return false
+       }
+       //return false if control_key_codes[e.keyCode] != null
+       switch (e.keyCode) {
+               case KEY_LEFT:
+                       if (this.cursor != null) {
+                               new_cursor = find_prev_cursor_position(this.tree, this.cursor)
+                       } else {
+                               new_cursor = first_cursor_position(this.tree)
+                       }
+                       if (new_cursor != null) {
+                               this.move_cursor(new_cursor)
+                       }
+                       return false
+               case KEY_RIGHT:
+                       if (this.cursor != null) {
+                               new_cursor = find_next_cursor_position(this.tree, this.cursor)
+                       } else {
+                               new_cursor = last_cursor_position(this.tree)
+                       }
+                       if (new_cursor != null) {
+                               this.move_cursor(new_cursor)
+                       }
+                       return false
+               case KEY_UP:
+                       if (this.cursor != null) {
+                               new_cursor = find_up_cursor_position(this.tree, this.cursor, this.cursor_ideal_x)
+                               if (new_cursor != null) {
+                                       saved_ideal_x = this.cursor_ideal_x
+                                       this.move_cursor(new_cursor)
+                                       this.cursor_ideal_x = saved_ideal_x
+                               }
+                       } else {
+                               // move cursor to first position in document
+                               new_cursor = first_cursor_position(this.tree)
+                               if (new_cursor != null) {
+                                       this.move_cursor(new_cursor)
+                               }
+                       }
+                       return false
+               case KEY_DOWN:
+                       if (this.cursor != null) {
+                               new_cursor = find_down_cursor_position(this.tree, this.cursor, this.cursor_ideal_x)
+                               if (new_cursor != null) {
+                                       saved_ideal_x = this.cursor_ideal_x
+                                       this.move_cursor(new_cursor)
+                                       this.cursor_ideal_x = saved_ideal_x
+                               }
+                       } else {
+                               // move cursor to first position in document
+                               new_cursor = last_cursor_position(this.tree)
+                               if (new_cursor != null) {
+                                       this.move_cursor(new_cursor)
+                               }
+                       }
+                       return false
+               case KEY_END:
+                       new_cursor = last_cursor_position(this.tree)
+                       if (new_cursor != null) {
+                               this.move_cursor(new_cursor)
+                       }
+                       return false
+               case KEY_BACKSPACE:
+                       this.on_key_backspace(e)
+                       return false
+               case KEY_DELETE:
+                       if (this.cursor == null) {
+                               return false
+                       }
+                       new_cursor = find_next_cursor_position(this.tree, {n: this.cursor.n, i: this.cursor.i})
+                       // try moving cursor right and then running backspace code
+                       // TODO replace this hack with a real implementation
+                       if (new_cursor != null) {
+                               // try to detect common case where cursor goes inside an block,
+                               // but doesn't pass a character (and advance one more in that case)
+                               if (new_cursor.n !== this.cursor.n && new_cursor.i === 0) {
+                                       if (new_cursor.n.type === 'text' && new_cursor.n.text.length > 0) {
+                                               if (new_cursor.n.parent != null) {
+                                                       if (!this.is_display_block(new_cursor.n.parent)) {
+                                                               // FIXME should test run sibling
+                                                               new_cursor = new_cursor_position({n: new_cursor.n, i: new_cursor.i + 1})
+                                                       }
+                                               }
+                                       }
+                               }
+                       }
+                       if (new_cursor != null) {
+                               if (new_cursor.n !== this.cursor.n || new_cursor.i !== this.cursor.i) {
+                                       this.move_cursor(new_cursor)
+                                       this.on_key_backspace(e)
+                               }
+                       }
+                       return false
+               case KEY_ENTER:
+                       this.on_key_enter(e)
+                       return false
+               case KEY_ESCAPE:
+                       this.kill_cursor()
+                       return false
+               case KEY_HOME:
+                       new_cursor = first_cursor_position(this.tree)
+                       if (new_cursor != null) {
+                               this.move_cursor(new_cursor)
+                       }
+                       return false
+               case KEY_INSERT:
+                       return false
+               case KEY_PAGE_UP:
+                       this.on_page_up_key(e)
+                       return false
+               case KEY_PAGE_DOWN:
+                       this.on_page_down_key(e)
+                       return false
+               case KEY_TAB:
+                       return false
+       }
+}
+PeachHTML5Editor.prototype.onkeypress = function(e) {
+       var char, new_cursor
+       if (e.ctrlKey) {
+               return
+       }
+       if (ignore_key_codes[e.keyCode] != null) {
+               return false
+       }
+       char = e.charCode != null ? e.charCode : e.keyCode
+       if (char && (this.cursor != null)) {
+               char = String.fromCharCode(char)
+               this.insert_character(this.cursor.n, this.cursor.i, char)
+               this.text_cleanup(this.cursor.n)
+               this.changed()
+               new_cursor = new_cursor_position({n: this.cursor.n, i: this.cursor.i + 1})
+               if (new_cursor) {
+                       this.move_cursor(new_cursor)
+               } else {
+                       console.log("ERROR: couldn't find cursor position after insert")
+                       this.kill_cursor()
+               }
+       }
+       return false
+}
+PeachHTML5Editor.prototype.on_key_enter = function(e) { // enter key pressed
+       var before, cur_block, i, n, new_cursor, new_node, new_text, parent_el, pc
+       if (!this.cursor_visible) {
+               return
+       }
+       cur_block = this.cursor.n
+       while (true) {
+               if (cur_block.type === 'tag') {
+                       if (is_display_block(cur_block.el)) {
+                               break
+                       }
+               }
+               if (cur_block.parent == null) {
+                       return
+               }
+               cur_block = cur_block.parent
+       }
+       // find array to insert new element into
+       if (cur_block.parent === this.tree_parent) {
+               parent_el = this.idoc.body
+               pc = this.tree
+       } else {
+               parent_el = cur_block.parent.el
+               pc = cur_block.parent.children
+       }
+       for (i = 0; i < pc.length; ++i) {
+               n = pc[i]
+               if (n === cur_block) {
+                       break
+               }
+       }
+       i += 1 // we want to be after it
+       if (i < pc.length) {
+               before = pc[i].el
+       } else {
+               before = null
+       }
+       // TODO if content after cursor
+       // TODO new block is empty
+       new_text = new peach_parser.Node('text', {text: ' '})
+       new_node = new peach_parser.Node('tag', {
+               name: 'p',
+               parent: cur_block.parent,
+               attrs: {style: 'white-space: pre-wrap'},
+               children: [new_text]
+       })
+       new_text.parent = new_node
+       new_text.el = domify(this.idoc, {text: ' '})
+       new_node.el = domify(this.idoc, {p: {style: 'white-space: pre-wrap', children: [new_text.el]}})
+       pc.splice(i, 0, new_node)
+       parent_el.insertBefore(new_node.el, before)
+       this.changed()
+       new_cursor = new_cursor_position({
+               n: new_text,
+               i: 0
+       })
+       if (new_cursor == null) {
+               throw 'bork bork'
+       }
+       this.move_cursor(new_cursor)
+       // TODO move content past cursor into this new block
+       return false
+}
+// unlike the global function, this takes a Node, not an element
+PeachHTML5Editor.prototype.is_display_block = function(n) {
+       // TODO stop calling global function, merge it into here, use iframe's window object
+       if (n.type !== 'tag') {
+               return false
+       }
+       return is_display_block(n.el)
+}
+PeachHTML5Editor.prototype.find_block_parent = function(n) {
+       while (true) {
+               n = n.parent
+               if (n == null) {
+                       return null
+               }
+               if (this.is_display_block(n)) {
+                       return n
+               }
+               if (n === this.tree_parent) {
+                       return n
+               }
+       }
+       return null
+}
+// return a flat array of nodes (text, <br>, and later also inline-block)
+// that are flowing/wrapping together. n can be the containing block, or any
+// element inside it.
+PeachHTML5Editor.prototype.get_text_run = function(n) {
+       var block, ret
+       ret = []
+       if (this.is_display_block(n)) {
+               block = n
+       } else {
+               block = this.find_block_parent(n)
+               if (block == null) {
+                       return ret
+               }
+       }
+       traverse_tree(block.children, (function(_this) { return function(n) {
+               var disp
+               if (n.type === 'text') {
+                       ret.push(n)
+               } else if (n.type === 'tag') {
+                       if (n.name === 'br') {
+                               ret.push(n)
+                       } else {
+                               disp = _this.computed_style(n)
+                               if (disp === 'inline-block') {
+                                       ret.push(n)
+                               }
+                       }
+               }
+               return false // not done traversing
+       }})(this))
+       return ret
+}
+PeachHTML5Editor.prototype.node_is_decendant = function(young, old) {
+       while (young != null && young !== this.tree_parent) {
+               if (young === old) {
+                       return true
+               }
+               young = young.parent
+       }
+       return false
+}
+// helper for on_key_backspace
+PeachHTML5Editor.prototype._merge_left = function(state) {
+       var pi, prev
+       // the node prev to n was not prev to it a moment ago, merge with it if reasonable
+       pi = state.n.parent.children.indexOf(state.n)
+       if (pi > 0) {
+               prev = state.n.parent.children[pi - 1]
+               if (prev.type === 'text') {
+                       state.i = prev.text.length
+                       prev.text = prev.el.textContent = prev.text + state.n.text
+                       this.remove_node(state.n)
+                       state.n = prev
+                       state.changed = true
+                       state.moved_cursor = true
+               }
+       }
+       // else // TODO merge possible consecutive matching inline tags at @cursor
+       return state
+}
+// helper for on_key_backspace
+// remove n from the dom, also remove its inline parents that are emptied by removing n
+PeachHTML5Editor.prototype._backspace_node_helper = function(n, run, run_i) {
+       var block
+       if (run == null) {
+               run = this.get_text_run(n)
+       }
+       if (run_i == null) {
+               run_i = run.indexOf(n)
+       }
+       block = this.find_block_parent(n)
+       this.remove_node(n)
+       n = n.parent
+       while (n != null && n !== block) {
+               // bail if the previous node in this run is also inside the same parent
+               if (run_i > 0) {
+                       if (this.node_is_decendant(run[run_i - 1], n)) {
+                               break
+                       }
+               }
+               // bail if the next node in this run is also inside the same parent
+               if (run_i + 1 < run.length) {
+                       if (this.node_is_decendant(run[run_i + 1], n)) {
+                               break
+                       }
+               }
+               // move any sibling nodes to parent. These nodes are not in the text run
+               while (n.children.length > 0) {
+                       this.move_node(n.children[0], n.parent, n)
+               }
+               // remove (now completely empty) inline parent
+               this.remove_node(n)
+               // proceed to outer parent
+               n = n.parent
+       }
+}
+PeachHTML5Editor.prototype.on_key_backspace = function(e) {
+       var block, changed, merge_state, n, ncb, need_text_cleanup, new_cursor, pcb, post, pre, prev, prev_cursor, run, run_i
+       if (this.cursor == null) {
+               return
+       }
+       new_cursor = null
+       run = null
+       changed = true
+       if (this.cursor.i === 0) { // cursor is at start of text node
+               if (run == null) {
+                       run = this.get_text_run(this.cursor.n)
+               }
+               run_i = run.indexOf(this.cursor.n)
+               if (run_i === 0) { // if at start of text run
+                       block = this.find_block_parent(this.cursor.n)
+                       prev_cursor = find_prev_cursor_position(this.tree, {n: this.cursor.n, i: 0})
+                       if (prev_cursor === null) { // if in first text run of document
+                               // do nothing (there's nothing text-like to the left of the cursor)
+                               return
+                       }
+                       // else merge with prev/outer text run
+                       pcb = this.find_block_parent(prev_cursor.n)
+                       while (block.children.length > 0) {
+                               this.move_node(block.children[0], pcb)
+                       }
+                       this.remove_node(block)
+                       // merge possible consecutive text nodes at @cursor
+                       merge_state = {n: this.cursor.n}
+                       this._merge_left(merge_state)
+                       this.text_cleanup(merge_state.n)
+                       new_cursor = new_cursor_position({n: merge_state.n, i: merge_state.i})
+               } else { // at start of text node, but not start of text run
+                       prev = run[run_i - 1]
+                       if (prev.type === 'text') { // if previous in text run is text
+                               if (prev.text.length === 1) { // if emptying prev (in text run)
+                                       this._backspace_node_helper(prev, run, run_i)
+                                       merge_state = {n: this.cursor.n, i: this.cursor.i}
+                                       this._merge_left(merge_state)
+                                       this.text_cleanup(merge_state.n)
+                                       new_cursor = new_cursor_position({n: merge_state.n, i: merge_state.i})
+                               } else { // prev in run is text with muliple chars
+                                       // delete last character in prev
+                                       prev.text = prev.text.substr(0, prev.text.length - 1)
+                                       prev.el.textContent = prev.text
+                                       this.text_cleanup(this.cursor.n)
+                                       new_cursor = new_cursor_position({n: this.cursor.n, i: this.cursor.i})
+                               }
+                       } else if (prev.name === 'br' || prev.name === 'hr') {
+                               this._backspace_node_helper(prev, run, run_i)
+                               merge_state = {n: this.cursor.n, i: this.cursor.i}
+                               this._merge_left(merge_state)
+                               this.text_cleanup(merge_state.n)
+                               new_cursor = new_cursor_position({n: merge_state.n, i: merge_state.i})
+                       }
+                       // FIXME implement this:
+                       // else // if prev (in run) is inline-block
+                               // if that inline-block has text in it
+                                       // delete last char in prev inlineblock
+                                       // if that empties it
+                                               // delete it
+                                               // merge left
+                                       // else
+                                               // move cursor inside
+                               // else
+                                       // delete prev (inline) block
+                                       // merge left
+                               // auto-delete this @cursor.parent(s) if this empties them
+               }
+       } else { // cursor is not at start of text node
+               if (run == null) {
+                       run = this.get_text_run(this.cursor.n)
+               }
+               if (this.cursor.n.text.length === 1) { // if emptying text node
+                       if (run.length === 1) { // if emptying text run (of text/br/hr/inline-block)
+                               // remove inline-parents of @cursor.n
+                               block = this.find_block_parent(this.cursor.n)
+                               changed = false
+                               n = this.cursor.n.parent
+                               // note: this doesn't use _backspace_node_helper because:
+                               // 1. we don't want to delete the target node (we're replacing it's contents)
+                               // 2. we want to track whether anything was removed
+                               // 3. we know already know there's no other text from this run anywhere
+                               while (n && n !== block) {
+                                       changed = true
+                                       while (n.children.length > 0) {
+                                               this.move_node(n.children[0], n.parent, n)
+                                       }
+                                       this.remove_node(n)
+                                       n = n.parent
+                               }
+                               // replace @cursor.n with a single (preserved) space
+                               if (this.cursor.n.text !== ' ') {
+                                       changed = true
+                                       this.cursor.n.text = this.cursor.n.el.textContent = ' '
+                               }
+                               if (changed) {
+                                       this.text_cleanup(this.cursor.n)
+                               }
+                               // place the cursor to the left of that space
+                               new_cursor = new_cursor_position({n: this.cursor.n, i: 0})
+                       } else { // emptying a text node (but not a whole text run)
+                               // figure out where cursor should land
+                               block = this.find_block_parent(this.cursor.n)
+                               new_cursor = find_prev_cursor_position(this.tree, {n: this.cursor.n, i: 0})
+                               ncb = this.find_block_parent(new_cursor.n)
+                               if (ncb !== block) {
+                                       new_cursor = find_next_cursor_position(this.tree, {n: this.cursor.n, i: 1})
+                               }
+                               // delete text node and cleanup emptied parents
+                               run_i = run.indexOf(this.cursor.n)
+                               this._backspace_node_helper(this.cursor.n, run, run_i)
+                               // see if new adjacent siblings should merge
+                               // TODO make smarter
+                               if (run_i > 0 && run_i + 1 < run.length) {
+                                       if (run[run_i - 1].type === 'text' && run[run_i + 1].type === 'text') {
+                                               merge_state = {n: run[run_i + 1]}
+                                               this._merge_left(merge_state)
+                                               if (merge_state.moved_cursor) {
+                                                       new_cursor = merge_state
+                                               }
+                                       }
+                               }
+                               // update whitespace preservation
+                               this.text_cleanup(block)
+                               // update cursor x/y in case things moved around
+                               if (new_cursor != null) {
+                                       if (new_cursor.n.el.parentNode) { // still in dom after cleanup
+                                               new_cursor = new_cursor_position({n: new_cursor.n, i: new_cursor.i})
+                                       } else {
+                                               new_cursor = null
+                                       }
+                               }
+                       }
+               } else { // there's a char left of cursor that we can delete without emptying anything
+                       // delete character
+                       need_text_cleanup = true
+                       if (this.cursor.i > 1 && this.cursor.i < this.cursor.n.text.length) {
+                               pre = this.cursor.n.text.substr(this.cursor.i - 2, 3)
+                               post = pre.charAt(0) + pre.charAt(2)
+                               if (str_has_ws_run(pre) === str_has_ws_run(post)) {
+                                       need_text_cleanup = false
+                               }
+                       }
+                       this.remove_character(this.cursor.n, this.cursor.i - 1)
+                       // call text_cleanup if whe created/removed a whitespace run
+                       if (need_text_cleanup) {
+                               this.text_cleanup(this.cursor.n)
+                       }
+                       new_cursor = new_cursor_position({n: this.cursor.n, i: this.cursor.i - 1})
+               }
+       }
+       // mark document changed and move the cursor
+       if (changed != null) {
+               this.changed()
+       }
+       if (new_cursor != null) {
+               this.move_cursor(new_cursor)
+       } else {
+               this.kill_cursor()
+       }
+}
+PeachHTML5Editor.prototype.on_page_up_key = function(e) {
+       var new_cursor, screen_y, scroll_amount
+       if (this.wrap2.scrollTop === 0) {
+               if (this.cursor == null) {
+                       return
+               }
+               new_cursor = first_cursor_position(this.tree)
+               if (new_cursor != null) {
+                       if (new_cursor.n !== this.cursor.n || new_cursor.i !== this.cursor.i) {
+                               this.move_cursor(new_cursor)
+                       }
+               }
+               return
+       }
+       if (this.cursor != null) {
+               screen_y = this.cursor.y - this.wrap2.scrollTop
+       }
+       scroll_amount = this.wrap2_height - breathing_room
+       this.wrap2.scrollTop = Math.max(0, this.wrap2.scrollTop - scroll_amount)
+       if (this.cursor != null) {
+               return this.move_cursor_into_view(screen_y + this.wrap2.scrollTop)
+       }
+}
+PeachHTML5Editor.prototype.on_page_down_key = function(e) {
+       var lowest_scrollpos, new_cursor, screen_y, scroll_amount
+       lowest_scrollpos = this.wrap2.scrollHeight - this.wrap2_height
+       if (this.wrap2.scrollTop === lowest_scrollpos) {
+               if (this.cursor == null) {
+                       return
+               }
+               new_cursor = last_cursor_position(this.tree)
+               if (new_cursor != null) {
+                       if (new_cursor.n !== this.cursor.n || new_cursor.i !== this.cursor.i) {
+                               this.move_cursor(new_cursor)
+                       }
+               }
+               return
+       }
+       if (this.cursor != null) {
+               screen_y = this.cursor.y - this.wrap2.scrollTop
+       }
+       scroll_amount = this.wrap2_height - breathing_room
+       this.wrap2.scrollTop = Math.min(lowest_scrollpos, this.wrap2.scrollTop + scroll_amount)
+       if (this.cursor != null) {
+               this.move_cursor_into_view(screen_y + this.wrap2.scrollTop)
+       }
+}
+PeachHTML5Editor.prototype.move_cursor_into_view = function(y_target) {
+       var cur, far_enough, finder, new_cursor, saved_ideal_x, was, y_max, y_min
+       if (y_target === this.cursor.y) {
+               return
+       }
+       was = this.cursor
+       y_min = this.wrap2.scrollTop
+       if (this.wrap2.scrollTop !== 0) {
+               y_min += breathing_room
+       }
+       y_max = this.wrap2.scrollTop + this.wrap2_height
+       if (this.wrap2.scrollTop !== this.wrap2.scrollHeight - this.wrap2_height) { // downmost
+               y_max -= breathing_room
+       }
+       y_target = Math.min(y_target, y_max)
+       y_target = Math.max(y_target, y_min)
+       if (y_target < this.cursor.y) {
+               finder = find_up_cursor_position
+               far_enough = function(cur, target_y) {
+                       return cur.y + cur.h <= target_y
+               }
+       } else {
+               finder = find_down_cursor_position
+               far_enough = function(cur, y_target) {
+                       return cur.y >= y_target
+               }
+       }
+       while (true) {
+               cur = finder(this.tree, was, this.cursor_ideal_x)
+               if (cur == null) {
+                       break
+               }
+               if (far_enough(cur, y_target)) {
+                       break
+               }
+               was = cur
+       }
+       if (was === this.cursor) {
+               was = null
+       }
+       if (was != null) {
+               if (was.y + was.h > y_max) {
+                       was = null
+               } else if (was.y < y_min) {
+                       was = null
+               }
+       }
+       if (cur != null) {
+               if (cur.y + cur.h > y_max) {
+                       cur = null
+               } else if (cur.y < y_min) {
+                       cur = null
+               }
+       }
+       if ((cur != null) && (was != null)) {
+               // both valid, pick best
+               if (cur.y < y_min) {
+                       new_cursor = was
+               } else if (was.y + was.h > y_max) {
+                       new_cursor = cur
+               } else if (cur.y - y_target < y_target - was.y) {
+                       new_cursor = cur
+               } else {
+                       new_cursor = was
+               }
+       } else {
+               new_cursor = was != null ? was : cur
+       }
+       if (new_cursor != null) {
+               saved_ideal_x = this.cursor_ideal_x
+               this.move_cursor(new_cursor)
+               this.cursor_ideal_x = saved_ideal_x
+       }
+}
+// remove all the editable content (and cursor, overlays, etc)
+PeachHTML5Editor.prototype.clear_dom = function() {
+       while (this.idoc.body.childNodes.length) {
+               this.idoc.body.removeChild(this.idoc.body.childNodes[0])
+       }
+       this.kill_cursor()
+}
+PeachHTML5Editor.prototype.load_html = function(html) {
+       this.tree = peach_parser(html, this.parser_opts)
+       if (this.tree[0] == null ? true : this.tree[0].parent == null) {
+               this.tree = peach_parser('<p style="white-space: pre-wrap"> </p>', this.parser_opts)
+       }
+       this.tree_parent = this.tree[0].parent
+       this.tree_parent.el = this.idoc.body
+       this.clear_dom()
+       instantiate_tree(this.tree, this.tree_parent.el)
+       this.collapse_whitespace(this.tree)
+       return this.changed()
+}
+PeachHTML5Editor.prototype.changed = function() {
+       this.in_el.onchange = null
+       this.in_el.value = this.pretty_html(this.tree)
+       this.in_el.onchange = (function(_this) { return function() {
+               return _this.load_html(_this.in_el.value)
+       }})(this)
+       return this.adjust_iframe_height()
+}
+PeachHTML5Editor.prototype.adjust_iframe_height = function() {
+       var h, s
+       s = this.wrap2.scrollTop
+       // when the content gets shorter, the idoc's body tag will continue to
+       // report the old (too big) height in Chrome. The workaround is to
+       // shrink the iframe before the content height:
+       this.iframe.style.height = "10px"
+       h = parseInt(this.idoc.body.scrollHeight, 10)
+       this.iframe.style.height = h + "px"
+       return this.wrap2.scrollTop = s
+}
+// true if n is text node with only one caracter, and the only child of a tag
+PeachHTML5Editor.prototype.is_only_char_in_tag = function(n, i) {
+       if (n.type !== 'text') {
+               return false
+       }
+       if (n.text.length !== 1) {
+               return false
+       }
+       if (n.parent === this.tree_parent) {
+               return false
+       }
+       if (n.parent.children.length !== 1) {
+               return false
+       }
+       return true
+}
+// true if n is text node with just a space in it, and the only child of a tag
+PeachHTML5Editor.prototype.is_lone_space = function(n, i) {
+       if (n.type !== 'text') {
+               return false
+       }
+       if (n.text !== ' ') {
+               return false
+       }
+       if (n.parent === this.tree_parent) {
+               return false
+       }
+       if (n.parent.children.length !== 1) {
+               return false
+       }
+       return true
+}
+// detect special case: typing before a space that's the only thing in a block/doc
+// reason: enter key creates blocks with just a space in them
+PeachHTML5Editor.prototype.insert_should_replace = function(n, i) {
+       if (i !== 0) {
+               return false
+       }
+       if (n.text !== ' ') {
+               return false
+       }
+       if (n.parent === this.tree_parent) {
+               return true
+       }
+       if (n.parent.children.length === 1) {
+               if (n.parent.children[0] === n) {
+                       // n is only child
+                       return true
+               }
+       }
+       return false
+}
+// WARNING:  after calling this, you MUST call changed() and text_cleanup()
+PeachHTML5Editor.prototype.insert_character = function(n, i, char) {
+       if (n.parent === this.tree_parent) {
+               // FIXME implement text nodes at top level
+               return
+       }
+       // insert the character
+       if (this.insert_should_replace(n, i)) {
+               n.text = char
+       } else if (i === 0) {
+               n.text = char + n.text
+       } else if (i === n.text.length) {
+               n.text += char
+       } else {
+               n.text = n.text.substr(0, i) + char + n.text.substr(i)
+       }
+       return n.el.nodeValue = n.text
+}
+// WARNING: after calling this, you MUST call changed() and text_cleanup()
+PeachHTML5Editor.prototype.remove_character = function(n, i) {
+       n.text = n.text.substr(0, i) + n.text.substr(i + 1)
+       return n.el.nodeValue = n.text
+}
+PeachHTML5Editor.prototype.computed_style = function(n, prop) {
+       var style
+       if (n.type === 'text') {
+               n = n.parent
+       }
+       style = this.iframe.contentWindow.getComputedStyle(n.el, null)
+       return style.getPropertyValue(prop)
+}
+// returns the new white-space value that will preserve spaces for node n
+PeachHTML5Editor.prototype.preserve_space = function(n, ideal_target) {
+       var target, ws, ref
+       if (n.type === 'text') {
+               target = n.parent
+       } else {
+               target = n
+       }
+       while (target !== ideal_target && !target.el.style.whiteSpace) {
+               if (target == null) {
+                       console.log("bug #967123")
+                       return
+               }
+               target = target.parent
+       }
+       ws = (ref = ws_props[target.el.style.whiteSpace]) != null ? ref.to_preserve : null
+       if (ws == null) {
+               ws = 'pre-wrap'
+       }
+       target.el.style.whiteSpace = ws
+       this.update_style_from_el(target)
+       return ws
+}
+PeachHTML5Editor.prototype.update_style_from_el = function(n) {
+       var style
+       style = n.el.getAttribute('style')
+       if (style != null) {
+               return n.attrs.style = style
+       } else {
+               if (n.attrs.style != null) {
+                       return delete n.attrs.style
+               }
+       }
+}
+// remove whitespace that would be trimmed
+// replace whitespace that would collapse with a single space
+// FIXME remove whitespace from after <br> (but not before)
+// FIXME rewrite to
+//     check computed white-space prop on txt parents
+//     batch replace txt node contents (ie don't loop for each char)
+PeachHTML5Editor.prototype.collapse_whitespace = function(tree) {
+       var cur, cur_i, cur_px, first, iterate, next, next_i, next_pos, next_px, operate, pos, prev, prev_i, prev_pos, prev_px, queue, remove, removed_char, replace_with_space
+       if (tree == null) {
+               tree = this.tree
+       }
+       prev = cur = next = null
+       prev_i = cur_i = next_i = 0
+       prev_pos = pos = next_pos = null
+       prev_px = cur_px = next_px = null
+       first = true
+       removed_char = null
+
+       tree_remove_empty_text_nodes(tree)
+
+       iterate = function(tree, cb) {
+               var advance, block, i, j, n
+               for (j = 0; j < tree.length; j++) {
+                       n = tree[j]
+                       if (n.type === 'text') {
+                               i = 0
+                               while (i < n.text.length) { // don't foreach, cb might remove chars
+                                       advance = cb(n, i)
+                                       if (advance) {
+                                               i += 1
+                                       }
+                               }
+                       }
+                       if (n.type === 'tag') {
+                               block = is_display_block(n.el)
+                               if (block) {
+                                       cb(null)
+                               }
+                               if (n.children.length > 0) {
+                                       iterate(n.children, cb)
+                               }
+                               if (block) {
+                                       cb(null)
+                               }
+                       }
+               }
+       }
+       // remove cur char
+       remove = function(undo) {
+               if (undo) {
+                       cur.el.textContent = cur.text = (cur.text.substr(0, cur_i)) + removed_char + (cur.text.substr(cur_i))
+                       if (next === 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 === cur) { // in same text node
+                               if (next_i === 0) {
+                                       throw "how is this possible?"
+                               }
+                               next_i -= 1
+                       }
+                       return 1
+               }
+       }
+       replace_with_space = function(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)
+                       if (removed_char !== ' ') {
+                               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 = function() {
+               // cur definitately set
+               // prev and/or next might be null, indicating the start/end of a display:block
+               var bounds, dbg, fixer, fixers, i, need_undo, new_next_px, new_prev_px, removed, undo_arg
+               if (!is_space_code(cur.text.charCodeAt(cur_i))) {
+                       return false
+               }
+               fixers = [remove, replace_with_space]
+               // check for common case: single whitespace surrounded by non-whitespace chars
+               if ((prev != null) && (next != null)) {
+                       if (!((is_space_code(prev.text.charCodeAt(prev_i))) || (is_space_code(next.text.charCodeAt(next_i))))) {
+                               dbg = cur.text.charCodeAt(cur_i)
+                               if (cur.text.charAt(cur_i) === ' ') {
+                                       return false
+                               } else {
+                                       fixers = [replace_with_space]
+                               }
+                       }
+               }
+               bounds = text_range_bounds(cur.el, cur_i, cur_i + 1)
+               // consistent cases:
+               // 1. zero rects returned by getClientRects() means collapsed space
+               if (bounds === null) {
+                       return remove()
+               }
+               // 2. width greater than zero means visible space
+               if (bounds.w > 0) {
+                       // has bounds, don't try removing
+                       fixers = [replace_with_space]
+               }
+               // now the weird edge cases...
+               //
+               // firefox and chromium both report zero width for characters at the end
+               // of a line where the text wraps (automatically, due to word-wrap) to
+               // the next line. These do not appear to be distinguishable from
+               // collapsed spaces via the range/bounds api, so...
+               //
+               // remove it from the dom, and if prev or next moves, put it back.
+               //
+               // this block (try changing it, put it back if something moves) is also
+               // used on collapsable whitespace characters besides space. In this case
+               // the character is replaced with a normal space character instead of
+               // removed
+               if ((prev != null) && (prev_px == null)) {
+                       prev_px = new_cursor_position({n: prev, i: prev_i})
+               }
+               if ((next != null) && (next_px == null)) {
+                       next_px = new_cursor_position({n: next, i: next_i})
+               }
+               //if prev is null and next is null
+               //      parent_px = cur.parent.el.getBoundingClientRect()
+               undo_arg = true // just for readabality
+               removed = 0
+               for (i = 0; i < fixers.length; i++) {
+                       fixer = fixers[i]
+                       if (removed > 0) {
+                               break
+                       }
+                       removed += fixer()
+                       need_undo = false
+                       if (prev != null) {
+                               if (prev_px != null) {
+                                       new_prev_px = new_cursor_position({n: prev, i: prev_i})
+                                       if (new_prev_px != null) {
+                                               if (new_prev_px.x !== prev_px.x || new_prev_px.y !== 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 != null) && !need_undo) {
+                               if (next_px != null) {
+                                       new_next_px = new_cursor_position({n: next, i: next_i})
+                                       if (new_next_px != null) {
+                                               if (new_next_px.x !== next_px.x || new_next_px.y !== 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 = function(n, i) {
+               var advance, removed
+               next = n
+               next_i = i
+               next_px = null
+               advance = true
+               if (cur != null) {
+                       removed = operate()
+                       // don't advance (to the next character next time) if we removed a
+                       // character from the same text node as ``next``, because doing so
+                       // renumbers the indexes in that string
+                       if (removed && cur === next) {
+                               advance = false
+                       }
+               } else {
+                       removed = false
+               }
+               if (!removed) {
+                       prev = cur
+                       prev_i = cur_i
+                       prev_px = cur_px
+               }
+               cur = next
+               cur_i = next_i
+               cur_px = next_px
+               return advance
+       }
+       queue(null)
+       iterate(tree, queue)
+       queue(null)
+
+       tree_remove_empty_text_nodes(tree)
+}
+// call this after you insert or remove inline nodes. It will:
+//    merge consecutive text nodes
+//    remove empty text nodes
+//    adjust white-space property
+// note: this assumes that all whitespace in text nodes should be displayed
+// (ie not collapse or be trimmed) and will change the white-space property
+// as needed to achieve this.
+PeachHTML5Editor.prototype.text_cleanup = function(n) {
+       var block, eats_start_sp, i, last, n_i, need_preserve, prev, prev_i, run, ws
+       if (this.is_display_block(n)) {
+               block = n
+       } else {
+               block = this.find_block_parent(n)
+               if (block == null) {
+                       return
+               }
+       }
+       run = this.get_text_run(block)
+       if (run == null) {
+               return
+       }
+       if (run.length > 1) {
+               i = 1
+               prev = run[0]
+               while (i < run.length) {
+                       n = run[i]
+                       if (prev.type === 'text' && n.type === 'text') {
+                               if (prev.parent === n.parent) {
+                                       prev_i = n.parent.children.indexOf(prev)
+                                       n_i = n.parent.children.indexOf(n)
+                                       if (n_i === prev_i + 1) {
+                                               prev.text = prev.text + n.text
+                                               prev.el.textContent = prev.text
+                                               this.remove_node(n)
+                                               run.splice(i, 1)
+                                               continue // don't increment i or change prev
+                                       }
+                               }
+                       }
+                       i += 1
+                       prev = n
+               }
+       }
+       // remove empty text nodes
+       i = 0
+       while (i < run.length) {
+               n = run[i]
+               if (n.type === 'text') {
+                       if (n.text === '') {
+                               this.remove_node(n)
+                               // FIXME maybe remove parents recursively if this makes them empty
+                               run.splice(i, 1)
+                               continue // don't increment i
+                       }
+               }
+               i += 1
+       }
+       // note: inline tags can have white-space:pre-line/etc
+       // note: inline-blocks have their whitespace collapsed independantly of outer run
+       // note: inline-blocks are treated like non-whitespace char even if empty
+       if (block.el.style.whiteSpace != null) {
+               ws = block.el.style.whiteSpace
+               if (ws_props[ws]) {
+                       if (ws_props[ws].space) {
+                               if (ws_props[ws].to_collapse === 'normal') {
+                                       block.el.style.whiteSpace = null
+                               } else {
+                                       block.el.style.whiteSpace = ws_props[ws].to_collapse
+                               }
+                               this.update_style_from_el(block)
+                       }
+               }
+       }
+       // note: space after <br> colapses, but not space before
+       // check for spaces that would collapse without help
+       eats_start_sp = true // if the next node starts with space it collapses (unless pre)
+       prev = null
+       for (i = 0; i < run.length; ++i) {
+               n = run[i]
+               if (n.type === 'tag') {
+                       if (n.name === 'br') {
+                               eats_start_sp = true
+                       } else {
+                               eats_start_sp = false
+                       }
+               } else {
+                       need_preserve = false
+                       if (n.type !== 'text') {
+                               console.log("bug #232308")
+                               return
+                       }
+                       if (eats_start_sp) {
+                               if (is_space_code(n.text.charCodeAt(0))) {
+                                       need_preserve = true
+                               }
+                       }
+                       if (!need_preserve) {
+                               need_preserve = multi_sp_regex.test(n.text)
+                       }
+                       if (need_preserve) {
+                               // do we have it already?
+                               ws = this.computed_style(n, 'white-space') // FIXME implement this
+                               if (ws_props[ws] == null ? true : ws_props[ws].space == null) {
+                                       // 2nd arg is ideal target for css rule
+                                       ws = this.preserve_space(n, block)
+                               }
+                               eats_start_sp = false
+                       } else {
+                               if (is_space_code(n.text.charCodeAt(n.text.length - 1))) {
+                                       ws = this.computed_style(n, 'white-space') // FIXME implement this
+                                       if ((ref1 = ws_props[ws]) != null ? ref1.space : void 0) {
+                                               eats_start_sp = false
+                                       } else {
+                                               eats_start_sp = true
+                                       }
+                               } else {
+                                       eats_start_sp = false
+                               }
+                       }
+               }
+       }
+       // check if text ends with a collapsable space
+       if (run.length > 0) {
+               last = run[run.length - 1]
+               if (last.type === 'text') {
+                       if (eats_start_sp) {
+                               this.preserve_space(last, block)
+                       }
+               }
+       }
+}
+PeachHTML5Editor.prototype.css_clear = function(n, prop) {
+       var css_delimiter_regex, i, styles
+       if (n.attrs.style == null) {
+               return
+       }
+       if (n.attrs.style === '') {
+               return
+       }
+       css_delimiter_regex = new RegExp('\s*;\s*', 'g') // FIXME make this global
+       styles = n.attrs.style.trim().split(css_delimiter)
+       if (!(styles.length > 0)) {
+               return
+       }
+       if (styles[styles.length - 1] === '') {
+               styles.pop()
+               if (!(styles.length > 0)) {
+                       return
+               }
+       }
+       i = 0
+       while (i < styles.length) {
+               if (styles[i].substr(0, 12) === 'white-space:') {
+                       styles.splice(i, 1)
+               } else {
+                       i += 1
+               }
+       }
+}
+// WARNING: after calling this one or more times, you MUST:
+//    if it's inline: call @text_cleanup
+//    call @changed()
+PeachHTML5Editor.prototype.remove_node = function(n) {
+       var i
+       i = n.parent.children.indexOf(n)
+       if (i === -1) {
+               throw "BUG #9187112313"
+       }
+       n.el.parentNode.removeChild(n.el)
+       n.parent.children.splice(i, 1)
+}
+// remove a node from the tree/dom, insert into new_parent before insert_before?end
+// WARNING: after calling this one or more times, you MUST:
+//    if it's inline: call @text_cleanup
+//    call @changed()
+PeachHTML5Editor.prototype.move_node = function(n, new_parent, insert_before) {
+       var before_i, i
+       if (insert_before == null) {
+               insert_before = null
+       }
+       i = n.parent.children.indexOf(n)
+       if (i === -1) {
+               throw "Error: tried to remove node, but it's not in it's parents list of children"
+               return
+       }
+       if (insert_before != null) {
+               before_i = new_parent.children.indexOf(insert_before)
+               if (i === -1) {
+                       throw "Error: tried to move a node to be before a non-existent node"
+               }
+               insert_before = insert_before.el
+       }
+       this.remove_node(n)
+       if (insert_before != null) {
+               new_parent.el.insertBefore(n.el, insert_before)
+               new_parent.children.splice(before_i, 0, n)
+       } else {
+               new_parent.el.appendChild(n.el, insert_before)
+               new_parent.children.push(n)
+       }
+       n.parent = new_parent
+}
+// remove it, forget where it was
+PeachHTML5Editor.prototype.kill_cursor = function() {
+       if (this.cursor_visible) {
+               this.cursor_el.parentNode.removeChild(this.cursor_el)
+               this.cursor_visible = false
+       }
+       this.cursor = null
+       this.annotate(null)
+}
+PeachHTML5Editor.prototype.move_cursor = function(cursor) {
+       var height
+       this.cursor_ideal_x = cursor.x
+       this.cursor = cursor
+       if (!this.cursor_visible) {
+               this.cursor_el = domify(this.outer_idoc, {div: { id: 'cursor'}})
+               this.overlay.appendChild(this.cursor_el)
+               this.cursor_visible = true
+       }
+       this.cursor_el.style.left = (cursor.x + overlay_padding - 1) + "px"
+       if (cursor.h < 5) {
+               height = 12
+       } else {
+               height = cursor.h
+       }
+       this.cursor_el.style.top = (cursor.y + overlay_padding + Math.round(height * .07)) + "px"
+       this.cursor_el.style.height = (Math.round(height * 0.82)) + "px"
+       this.annotate(cursor.n)
+       this.scroll_into_view(cursor.y, height)
+}
+PeachHTML5Editor.prototype.scroll_into_view = function(y, h) {
+       var downmost, upmost
+       if (h == null) {
+               h = 0
+       }
+       y += overlay_padding // convert units from @idoc to @wrap2
+       // very top of document
+       if (y <= breathing_room) {
+               this.wrap2.scrollTop = 0
+               return
+       }
+       // very bottom of document
+       if (y + h >= this.wrap2.scrollHeight - breathing_room) {
+               this.wrap2.scrollTop = this.wrap2.scrollHeight - this.wrap2_height
+               return
+       }
+       // The most scrolled up (lowest value for scrollTop) that would be OK
+       upmost = y + h + breathing_room - this.wrap2_height
+       upmost = Math.max(upmost, 0)
+       // the most scrolled down (highest value for scrollTop) that would be OK
+       downmost = y - breathing_room
+       downmost = Math.min(downmost, this.wrap2.scrollHeight - this.wrap2_height)
+       if (upmost > downmost) { // means h is too big to fit
+               // scroll so top is visible
+               this.wrap2.scrollTop = downmost
+               return
+       }
+       if (this.wrap2.scrollTop < upmost) {
+               this.wrap2.scrollTop = upmost
+               return
+       }
+       if (this.wrap2.scrollTop > downmost) {
+               this.wrap2.scrollTop = downmost
+               return
+       }
+}
+PeachHTML5Editor.prototype.annotate = function(n) {
+       var alpha, ann_box, ann_tag, bounds, prev_bounds
+       while (this.matting.length > 0) {
+               this.overlay.removeChild(this.matting[0])
+               this.matting.shift()
+       }
+       if (n == null) {
+               return
+       }
+       prev_bounds = {x: 0, y: 0, w: 0, h: 0}
+       alpha = 0.1
+       while (((n != null ? n.el : void 0) != null) && n !== this.tree_parent) {
+               if (n.type === 'text') {
+                       n = n.parent
+                       continue
+               }
+               bounds = get_el_bounds(n.el)
+               if (bounds == null) {
+                       return
+               }
+               if (bounds.x === prev_bounds.x && bounds.y === prev_bounds.y && bounds.w === prev_bounds.w && bounds.h === prev_bounds.h) {
+                       n = n.parent
+                       continue
+               }
+               ann_box = domify(this.outer_idoc, {div: {"class": 'ann_box', style: "left: " + (bounds.x - 1 + overlay_padding) + "px; top: " + (bounds.y - 2 + overlay_padding) + "px; width: " + bounds.w + "px; height: " + bounds.h + "px"}}) // outline: 1000px solid rgba(0,153,255,#{alpha});
+               this.overlay.appendChild(ann_box)
+               this.matting.push(ann_box)
+               ann_tag = domify(this.outer_idoc, {div: {"class": 'ann_tag', style: "left: " + (bounds.x + 1 + overlay_padding) + "px; top: " + (bounds.y - 7 + overlay_padding) + "px",children: [domify(this.outer_idoc, {text: " " + n.name + " "})]}})
+               this.overlay.appendChild(ann_tag)
+               this.matting.push(ann_tag)
+               n = n.parent
+               alpha *= 1.5
+       }
+}
+PeachHTML5Editor.prototype.pretty_html = function(tree, indent, parent_flags) {
+       var attr_keys, cs, display, float, i, j, in_flow, in_flow_block, inner_flags, is_block, is_br, is_text, k, n, next_indent, position, prev_in_flow_is_block, prev_in_flow_is_text, ret, visibility, want_nl, whitespace
+       if (indent == null) {
+               indent = ''
+       }
+       if (parent_flags == null) {
+               parent_flags = {
+                       pre_ish: false,
+                       block: true,
+                       want_nl: false
+               }
+       }
+       ret = ''
+       want_nl = parent_flags.want_nl
+       prev_in_flow_is_text = false
+       prev_in_flow_is_block = false
+       for (i = 0; i < tree.length; ++i) {
+               n = tree[i]
+               inner_flags = {
+                       want_nl: true
+               }
+               is_br = false
+               switch (n.type) {
+                       case 'tag':
+                               if (n.name === 'br') {
+                                       is_br = true
+                               }
+                               is_text = false
+                               if (n.el.currentStyle != null) {
+                                       cs = n.el.currentStyle
+                                       whitespace = cs['white-space']
+                                       display = cs['display']
+                                       position = cs['position']
+                                       float = cs['float']
+                                       visibility = cs['visibility']
+                               } else {
+                                       cs = this.iframe.contentWindow.getComputedStyle(n.el, null)
+                                       whitespace = cs.getPropertyValue('white-space')
+                                       display = cs.getPropertyValue('display')
+                                       position = cs.getPropertyValue('position')
+                                       float = cs.getPropertyValue('float')
+                                       visibility = cs.getPropertyValue('visibility')
+                               }
+                               if (n.name === 'textarea') {
+                                       inner_flags.pre_ish = true
+                               } else {
+                                       inner_flags.pre_ish = whitespace.substr(0, 3) === 'pre'
+                               }
+                               switch (float) {
+                                       case 'left':
+                                       case 'right':
+                                               in_flow = false
+                                       break
+                                       default:
+                                               switch (position) {
+                                                       case 'absolute':
+                                                       case 'fixed':
+                                                               in_flow = false
+                                                       break
+                                                       default:
+                                                               if ('display' === 'none') {
+                                                                       in_flow = false
+                                                               } else {
+                                                                       switch (visibility) {
+                                                                               case 'hidden':
+                                                                               case 'collapse':
+                                                                                       in_flow = false
+                                                                               break
+                                                                               default:
+                                                                                       in_flow = true
+                                                                       }
+                                                               }
+                                               }
+                               }
+                               switch (display) {
+                                       case 'inline':
+                                       case 'none':
+                                               inner_flags.block = false
+                                               is_block = in_flow_block = false
+                                       break
+                                       case 'inline-black':
+                                               inner_flags.block = true
+                                               is_block = in_flow_block = false
+                                       break
+                                       default:
+                                               inner_flags.block = true
+                                               is_block = true
+                                               in_flow_block = in_flow
+                               }
+                       break
+                       case 'text':
+                               is_text = true
+                               is_block = false
+                               in_flow = true
+                               in_flow_block = false
+                               break
+                       default: // 'comment', 'doctype'
+                               is_text = false
+                               is_block = false
+                               in_flow = false
+                               in_flow_block = false
+               }
+               // print whitespace if we can
+               if (!parent_flags.pre_ish) {
+                       if (!(prev_in_flow_is_text && is_br)) {
+                               if ((i === 0 && parent_flags.block) || in_flow_block || prev_in_flow_is_block) {
+                                       if (want_nl) {
+                                               ret += "\n"
+                                       }
+                                       ret += indent
+                               }
+                       }
+               }
+               switch (n.type) {
+                       case 'tag':
+                               ret += '<' + n.name
+                               attr_keys = []
+                               for (k in n.attrs) {
+                                       attr_keys.unshift(k)
+                               }
+                               //attr_keys.sort()
+                               for (j = 0; j < attr_keys.length; ++j) {
+                                       k = attr_keys[j]
+                                       ret += " " + k
+                                       if (n.attrs[k].length > 0) {
+                                               ret += "=\"" + (enc_attr(n.attrs[k])) + "\""
+                                       }
+                               }
+                               ret += '>'
+                               if (void_elements[n.name] == null) {
+                                       if (inner_flags.block) {
+                                               next_indent = indent + '    '
+                                       } else {
+                                               next_indent = indent
+                                       }
+                                       if (n.children.length) {
+                                               ret += this.pretty_html(n.children, next_indent, inner_flags)
+                                       }
+                                       ret += "</" + n.name + ">"
+                               }
+                               break
+                       case 'text':
+                               ret += enc_text(n.text)
+                               break
+                       case 'comment':
+                               ret += "<!--" + n.text + "-->" // TODO encode?
+                               break
+                       case 'doctype':
+                               ret += "<!DOCTYPE " + n.name
+                               if ((n.public_identifier != null) && n.public_identifier.length > 0) {
+                                       ret += " \"" + n.public_identifier + "\""
+                               }
+                               if ((n.system_identifier != null) && n.system_identifier.length > 0) {
+                                       ret += " \"" + n.system_identifier + "\""
+                               }
+                               ret += ">"
+               }
+               want_nl = true
+               if (in_flow) {
+                       prev_in_flow_is_text = is_text
+                       prev_in_flow_is_block = is_block || (in_flow && is_br)
+               }
+       }
+       if (tree.length) {
+               // output final newline if allowed
+               if (!parent_flags.pre_ish) {
+                       if (prev_in_flow_is_block || parent_flags.block) {
+                               ret += "\n" + (indent.substr(4))
+                       }
+               }
+       }
+       return ret
+}
+PeachHTML5Editor.prototype.onblur = function() {
+       this.kill_cursor()
+}
+PeachHTML5Editor.prototype.have_focus = function() {
+       this.editor_is_focused = true
+       this.poll_for_blur()
+}
+PeachHTML5Editor.prototype.poll_for_blur = function() {
+       if (this.poll_for_blur_timeout != null) {
+               return
+       }
+       this.poll_for_blur_timeout = timeout(150, (function(_this) { return function() {
+               next_frame(function() { // pause polling when browser knows we're not active/visible/etc.
+                       _this.poll_for_blur_timeout = null
+                       if (document.activeElement === _this.outer_iframe) {
+                               _this.poll_for_blur()
+                       } else {
+                               _this.editor_is_focused = false
+                               _this.onblur()
+                       }
+               })
+       }})(this))
+}
+
+window.peach_html5_editor = function() {
+       // coffeescript: return new PeachHTML5Editor args...
+       // compiles to below... there must be a better way
+       var args
+       args = 1 <= arguments.length ? slice.call(arguments, 0) : []
+       return (function(func, args, ctor) {
+               ctor.prototype = func.prototype
+               var child = new ctor, result = func.apply(child, args)
+               return Object(result) === result ? result : child
+       })(PeachHTML5Editor, args, function(){})
+}
+
+}).call(this)
+
+// test in browser: peach_html5_editor(document.getElementsByTagName('textarea')[0])
diff --git a/editor_tests.coffee b/editor_tests.coffee
deleted file mode 100644 (file)
index c45895a..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-in_el = document.getElementById 'in'
-button = document.getElementById 'button'
-button.parentNode.removeChild button
-window.editor = peach_html5_editor in_el, css_file: 'page_dark.css'
diff --git a/editor_tests.html b/editor_tests.html
deleted file mode 100644 (file)
index e519b8b..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-       <meta charset="UTF-8">
-       <link rel="icon" href="data:;base64,iVBORw0KGgo=">
-       <title>html editor tester</title>
-       <style>
-               textarea {
-                       box-sizing: border-box;
-                       width: 100%;
-               }
-       </style>
-</head>
-<body>
-       <h1>Peach HTML5 Editor test page (partially compiled version)</h1>
-       <p>This version of the editor test page requires that you've compiled parser.js (to speed up page load) but it does compile editor.coffee and editor_tests.coffee in the browser so you don't have to rebuild them every time you make a change.</p>
-       <form action="#" method="get">
-       <p>In:<br><textarea rows="9" cols="22" name="in" id="in">&lt;p&gt;Normal &lt;strong&gt;Bold &lt;em&gt; Italic+Bold&lt;/strong&gt; Italic&lt;/em&gt; Normal&lt;/p&gt;</textarea></p>
-       <p><input id="button" type="submit" value="loading..." disabled></p>
-       </form>
-       <script src="parser.js"></script>
-       <script src="editor.coffee" type="text/coffeescript"></script>
-       <script src="editor_tests.coffee" type="text/coffeescript"></script>
-       <script src="coffee-script.js"></script>
-       <p><a href="https://jasonwoof.com/gitweb/?p=peach-html5-editor.git;a=tree">Source</a> - AGPLv3+</p>
-</body>
-</html>
diff --git a/editor_tests_coffee.html b/editor_tests_coffee.html
deleted file mode 100644 (file)
index 74a1cc8..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-       <meta charset="UTF-8">
-       <link rel="icon" href="data:;base64,iVBORw0KGgo=">
-       <title>html editor tester</title>
-       <style>
-               textarea {
-                       box-sizing: border-box;
-                       width: 100%;
-               }
-       </style>
-</head>
-<body>
-       <h1>Peach HTML5 Editor test page (CoffeeScript version)</h1>
-       <p>This version of the test page compiles the CoffeeScript files in the browser. This is slower to load, but saves you having to rebuild as you work (or even install CoffeeScript).</p>
-       <form action="#" method="get">
-       <p>In:<br><textarea rows="9" cols="22" name="in" id="in">&lt;p&gt;Normal &lt;strong&gt;Bold &lt;em&gt; Italic+Bold&lt;/strong&gt; Italic&lt;/em&gt; Normal&lt;/p&gt;</textarea></p>
-       <p><input id="button" type="submit" value="loading..." disabled></p>
-       </form>
-       <script src="parser.coffee" type="text/coffeescript"></script>
-       <script src="editor.coffee" type="text/coffeescript"></script>
-       <script src="editor_tests.coffee" type="text/coffeescript"></script>
-       <script src="coffee-script.js"></script>
-       <p><a href="https://jasonwoof.com/gitweb/?p=peach-html5-editor.git;a=tree">Source</a> - AGPLv3+</p>
-</body>
-</html>
diff --git a/editor_tests_compiled.html b/editor_tests_compiled.html
deleted file mode 100644 (file)
index 61b3ccf..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-       <meta charset="UTF-8">
-       <link rel="icon" href="data:null">
-       <title>html editor tester</title>
-       <link rel="stylesheet" href="page_dark.css">
-       <style>
-               textarea {
-                       box-sizing: border-box;
-                       width: 100%;
-                       height: 200px
-               }
-               /* optional */
-               .peach_html5_editor {
-                       width: 500px;
-                       height: 500px;
-                       margin: 0 auto;
-               }
-       </style>
-</head>
-<body>
-       <h1>Peach HTML5 Editor test page (compiled version)</h1>
-       <p>This color scheme is just temporary, for testing the cursor and annotations on a variaty of background colors</p>
-       <form action="#" method="get">
-       <p>HTML view. Changes here propagate when you remove your cursor (press tab or click outside)<br><textarea name="in" id="in">&lt;H1>Headline!&lt;/h1>&lt;p&gt;  normal text that is hopefully long enough that it will wrap     around\rand
-spill onto a second line.&lt;/p>    &lt;p  >Text   with lots of extra whitespace
-
-
-
-       in the original html  and no closing p tag       &lt;p>normal paragraph&lt;/p>
-       &lt;p>testing &amp;lt;br&amp;gt; e f&lt;br>g   &lt;br>  h i j &lt;a href="http://example.com">Click me!</a> o p q r&lt;/p>
-       &lt;div style="border: 2px solid #fab">
-       &lt;p> y z     &lt;strong&gt;Bold &lt;em&gt; Italic + Bold&lt;/strong&gt; Italic &lt;/em&gt; Normal&lt;/p&gt;
-&lt;p style="white-space: pre-wrap"&gt;this &amp;lt;p&amp;gt; has     white-space: pre-wrap&lt;/p&gt;
-
-&lt;div style="color: black; background: white;"&gt;
-       &lt;div&gt;I'm in a div&lt;/div&gt;
-       &lt;div&gt;I'm in another div&lt;/div&gt;
-&lt;div&gt;
-&lt;div&gt;
-&amp;nbsp;
-&lt;/div&gt;
-&lt;/div&gt;
-&lt;/div&gt;
-&lt;p>  Above, there's a white div containing 3 divs. The third contains a div which contains just a non-breaking space (&amp;amp;nbsp;)&lt;/p>
-&lt;p>final paragraph.&lt;/p>
-&lt;/div>
-       </textarea></p>
-       <p><input id="button" type="submit" value="loading..." disabled></p>
-       </form>
-       <script src="parser.js"></script>
-       <script src="editor.js"></script>
-       <script src="editor_tests.js"></script>
-       <p><a href="https://jasonwoof.com/gitweb/?p=peach-html5-editor.git;a=tree">Source</a> - <a href="https://gnu.org/licenses/agpl.html">AGPLv3+</a></p>
-</body>
-</html>
index 7154b4d..33c0d9d 100644 (file)
 
        <p>The editor is written in coffeescript, which compiles to javascript. To make it so people can try it out, and even do some development without installing the coffescript compiler, a variety of HTML files are provided, which allow some or all of the coffeescript files to be used directly in the browser.</p>
 
-       <h2>Demo</h2>
-
-       <p><a href="editor_tests_compiled.html">compiled version</a> (this requires running <code>make</code> first)</p>
-
-       <p><a href="editor_tests_coffee.html">no-prerequisites version</a> (slow, but you don't need to compile firest)</p>
-
-       <p><a href="editor_tests.html">developer/hybrid version</a> (this version requires that html_parser.js is already compiled)</p>
+       <p><a href="demo.html">Run the demo!</a></p>
 </body>
 </html>
index de9e9ed..e29b3bd 100644 (file)
--- a/parser.js
+++ b/parser.js
@@ -6076,4 +6076,6 @@ if (context === 'module') {
        window.peach_parser = parse_html
 }
 
+parse_html.Node = Node
+
 }).call(this)