JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
update comments
[peach-html5-editor.git] / editor.coffee
1 # Copyright 2015 Jason Woofenden
2 # This file implements an WYSIWYG editor in the browser (no contenteditable)
3 #
4 # This program is free software: you can redistribute it and/or modify it under
5 # the terms of the GNU Affero General Public License as published by the Free
6 # Software Foundation, either version 3 of the License, or (at your option) any
7 # later version.
8 #
9 # This program is distributed in the hope that it will be useful, but WITHOUT
10 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11 # FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
12 # details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17 # SETTINGS
18 overlay_padding = 10
19 breathing_room = 30 # minimum pixels above/below cursor (scrolling)
20
21 timeout = (ms, cb) -> return setTimeout cb, ms
22 next_frame = (cb) ->
23         if (window.requestAnimationFrame?)
24                 window.requestAnimationFrame cb
25         else
26                 timeout 16, cb
27
28 this_url_sans_path = ->
29         ret = "#{window.location.href}"
30         clip = ret.lastIndexOf '#'
31         if clip > -1
32                 ret = ret.substr 0, clip
33         clip = ret.lastIndexOf '?'
34         if clip > -1
35                 ret = ret.substr 0, clip
36         clip = ret.lastIndexOf '/'
37         if clip > -1
38                 ret = ret.substr 0, clip + 1
39         return ret
40
41 # table too look up the properties of various values for css's white-space
42 ws_props =
43         normal:
44                 space: false            # spaces are not preserved/rendered
45                 newline: false          # newlines are not preserved/rendered
46                 wrap: true              # text is word-wrapped
47                 to_preserve: 'pre-wrap' # to preservespaces, change white-space to this
48         nowrap:
49                 space: false
50                 newline: false
51                 wrap: false
52                 to_preserve: 'pre'
53         'pre-line':
54                 space: false
55                 newline: true
56                 wrap: true
57                 to_preserve: 'pre-wrap'
58         pre:
59                 space: true
60                 newline: true
61                 wrap: false
62                 to_collapse: 'nowrap'
63         'pre-wrap':
64                 space: true
65                 newline: true
66                 wrap: true
67                 to_collapse: 'normal'
68
69 # xml 1.0 spec, chromium and firefox accept these, plus lots of unicode chars
70 valid_attr_regex = new RegExp '^[a-zA-Z_:][-a-zA-Z0-9_:.]*$'
71 # html5 spec is much more lax, but chromium won't let me make at attribute with the name "4"
72 js_attr_regex = new RegExp '^[oO][nN].'
73 # html5 spec says that only these characters are collapsable
74 multi_sp_regex = new RegExp '[\u0020\u0009\u000a\u000c\u000d][\u0020\u0009\u000a\u000c\u000d]'
75
76 str_has_ws_run = (str) ->
77         return multi_sp_regex.test str
78
79 # text nodes don't have getBoundingClientRect(), so use selection api to find
80 # it.
81 get_el_bounds = window.bounds = (el) ->
82         if el.getBoundingClientRect?
83                 rect = el.getBoundingClientRect()
84         else
85                 # text nodes don't have getBoundingClientRect(), so use range api
86                 range = el.ownerDocument.createRange()
87                 range.selectNodeContents el
88                 rect = range.getBoundingClientRect()
89         doc = el.ownerDocument.documentElement
90         win = el.ownerDocument.defaultView
91         y_fix = win.pageYOffset - doc.clientTop
92         x_fix = win.pageXOffset - doc.clientLeft
93         return {
94                 x: rect.left + x_fix
95                 y: rect.top + y_fix
96                 w: rect.width ? (rect.right - rect.left)
97                 h: rect.height ? (rect.top - rect.bottom)
98         }
99
100 is_display_block = (el) ->
101         if el.currentStyle?
102                 return el.currentStyle.display is 'block'
103         else
104                 return window.getComputedStyle(el, null).getPropertyValue('display') is 'block'
105
106 # Pass return value from dom event handlers to this.
107 # If they return false, this will addinionally stop propagation and default.
108 event_return = (e, bool) ->
109         if bool is false
110                 if e.stopPropagation?
111                         e.stopPropagation()
112                 if e.preventDefault?
113                         e.preventDefault()
114         return bool
115 # Warning: currently assumes you're asking about a single character
116 # Note: chromium returns multiple bounding rects for a space at a line-break
117 # Note: chromium's getBoundingClientRect() is broken (when zero-area client rects)
118 # Note: sometimes returns null (eg for whitespace that is not visible)
119 text_range_bounds = (el, start, end) ->
120         range = document.createRange()
121         range.setStart el, start
122         range.setEnd el, end
123         rects = range.getClientRects()
124         if rects.length > 0
125                 if rects.length > 1
126                         if rects[1].width > rects[0].width
127                                 rect = rects[1]
128                         else
129                                 rect = rects[0]
130                 else
131                         rect = rects[0]
132         else
133                 return null
134         doc = el.ownerDocument.documentElement
135         win = el.ownerDocument.defaultView
136         y_fix = win.pageYOffset - doc.clientTop
137         x_fix = win.pageXOffset - doc.clientLeft
138         return {
139                 x: rect.left + x_fix
140                 y: rect.top + y_fix
141                 w: rect.width ? (rect.right - rect.left)
142                 h: rect.height ? (rect.top - rect.bottom)
143                 rects: rects
144                 bounding: range.getBoundingClientRect()
145         }
146
147 class CursorPosition
148         constructor: (args) ->
149                 @n = args.n ? null
150                 @i = args.i ? null
151                 if args.x?
152                         @x = args.x
153                         @y = args.y
154                         @h = args.h
155                 else
156                         @set_xyh()
157                 return
158         set_xyh: ->
159                 range = document.createRange()
160                 if @n.text.length is 0
161                         ret = text_range_bounds @n.el, 0, 0
162                 else if @i is @n.text.length
163                         ret = text_range_bounds @n.el, @i - 1, @i
164                         if ret?
165                                 ret.x += ret.w
166                 else
167                         ret = text_range_bounds @n.el, @i, @i + 1
168                 if ret?
169                         @x = ret.x
170                         @y = ret.y
171                         @h = ret.h
172                 else
173                         @x = null
174                         @y = null
175                         @h = null
176                 return ret
177
178 new_cursor_position = (args) ->
179         ret = new CursorPosition args
180         if ret.x?
181                 return ret
182         return null
183
184 # encode text so it can be safely placed inside an html attribute
185 enc_attr_regex = new RegExp '(&)|(")|(\u00A0)', 'g'
186 enc_attr = (txt) ->
187         return txt.replace enc_attr_regex, (match, amp, quote) ->
188                 return '&amp;' if (amp)
189                 return '&quot;' if (quote)
190                 return '&nbsp;'
191 enc_text_regex = new RegExp '(&)|(<)|(\u00A0)', 'g'
192 enc_text = (txt) ->
193         return txt.replace enc_text_regex, (match, amp, lt) ->
194                 return '&amp;' if (amp)
195                 return '&lt;' if (lt)
196                 return '&nbsp;'
197
198 void_elements = {
199         area: true
200         base: true
201         br: true
202         col: true
203         embed: true
204         hr: true
205         img: true
206         input: true
207         keygen: true
208         link: true
209         meta: true
210         param: true
211         source: true
212         track: true
213         wbr: true
214 }
215 # TODO make these always pretty-print (on the inside) like blocks
216 no_text_elements = { # these elements never contain text
217         select: true
218         table: true
219         tr: true
220         thead: true
221         tbody: true
222         ul: true
223         ol: true
224 }
225
226 domify = (doc, hash) ->
227         for tag, attrs of hash
228                 if tag is 'text'
229                         return document.createTextNode attrs
230                 el = document.createElement tag
231                 for k, v of attrs
232                         if k is 'children'
233                                 for child in v
234                                         el.appendChild child
235                         else
236                                 el.setAttribute k, v
237         return el
238
239
240
241 ignore_key_codes =
242         '18': true # alt
243         '20': true # capslock
244         '17': true # ctrl
245         '144': true # numlock
246         '16': true # shift
247         '91': true # windows "start" key
248 # key codes: (valid on keydown, not keypress)
249 KEY_LEFT = 37
250 KEY_UP = 38
251 KEY_RIGHT = 39
252 KEY_DOWN = 40
253 KEY_BACKSPACE = 8 # <--
254 KEY_DELETE = 46 # -->
255 KEY_END = 35
256 KEY_ENTER = 13
257 KEY_ESCAPE = 27
258 KEY_HOME = 36
259 KEY_INSERT = 45
260 KEY_PAGE_UP = 33
261 KEY_PAGE_DOWN = 34
262 KEY_TAB = 9
263 control_key_codes = # we react to these, but they aren't typing
264         '37': KEY_LEFT
265         '38': KEY_UP
266         '39': KEY_RIGHT
267         '40': KEY_DOWN
268         '35': KEY_END
269         '8':  KEY_BACKSPACE
270         '46': KEY_DELETE
271         '13': KEY_ENTER
272         '27': KEY_ESCAPE
273         '36': KEY_HOME
274         '45': KEY_INSERT
275         '33': KEY_PAGE_UP
276         '34': KEY_PAGE_DOWN
277         '9':  KEY_TAB
278
279 instantiate_tree = (tree, parent) ->
280         remove = []
281         for c, i in tree
282                 switch c.type
283                         when 'text'
284                                 c.el = parent.ownerDocument.createTextNode c.text
285                                 parent.appendChild c.el
286                         when 'tag'
287                                 if c.name in ['script', 'object', 'iframe', 'link']
288                                         # TODO put placeholders instead
289                                         remove.unshift i
290                                         continue
291                                 # TODO create in correct namespace
292                                 c.el = parent.ownerDocument.createElement c.name
293                                 for k, v of c.attrs
294                                         # FIXME if attr_whitelist[k]?
295                                         if valid_attr_regex.test k
296                                                 unless js_attr_regex.test k
297                                                         c.el.setAttribute k, v
298                                 parent.appendChild c.el
299                                 if c.children.length
300                                         instantiate_tree c.children, c.el
301         for i in remove
302                 tree.splice i, 1
303
304 traverse_tree = (tree, cb) ->
305         done = false
306         for c in tree
307                 done = cb c
308                 return done if done
309                 if c.children.length
310                         done = traverse_tree c.children, cb
311                         return done if done
312         return done
313
314 first_cursor_position = (tree) ->
315         found = null
316         traverse_tree tree, (node, state) ->
317                 if node.type is 'text'
318                         cursor = new_cursor_position n: node, i: 0
319                         if cursor?
320                                 found = cursor
321                                 return true # done traversing
322                 return false # not done traversing
323         return found # maybe null
324
325 # this will fail when text has non-locatable cursor positions
326 find_next_cursor_position = (tree, cursor) ->
327         if cursor.n.type is 'text' and cursor.n.text.length > cursor.i
328                 new_cursor = new_cursor_position n: cursor.n, i: cursor.i + 1
329                 if new_cursor?
330                         return new_cursor
331         state_before = true
332         found = null
333         traverse_tree tree, (node, state) ->
334                 if node.type is 'text' and state_before is false
335                         new_cursor = new_cursor_position n: node, i: 0
336                         if new_cursor?
337                                 found = new_cursor
338                                 return true # done traversing
339                 if node is cursor.n
340                         state_before = false
341                 return false # not done traversing
342         if found?
343                 return found
344         return null
345
346 last_cursor_position = (tree) ->
347         found = null
348         traverse_tree tree, (node) ->
349                 if node.type is 'text'
350                         cursor = new_cursor_position n: node, i: node.text.length
351                         if cursor?
352                                 found = cursor
353                 return false # not done traversing
354         return found # maybe null
355
356 # this will fail when text has non-locatable cursor positions
357 find_prev_cursor_position = (tree, cursor) ->
358         if cursor.n.type is 'text' and cursor.i > 0
359                 new_cursor = new_cursor_position n: cursor.n, i: cursor.i - 1
360                 if new_cursor?
361                         return new_cursor
362         found_prev = null
363         found = null
364         traverse_tree tree, (node) ->
365                 if node is cursor.n
366                         found = found_prev # maybe null
367                         return true # done traversing
368                 if node.type is 'text'
369                         new_cursor = new_cursor_position n: node, i: node.text.length
370                         if new_cursor?
371                                 found_prev = new_cursor
372                 return false # not done traversing
373         return found # maybe null
374
375 find_up_cursor_position = (tree, cursor, ideal_x) ->
376         new_cursor = cursor
377         # go prev until we're higher on y axis
378         while new_cursor.y >= cursor.y
379                 new_cursor = find_prev_cursor_position tree, new_cursor
380                 return null unless new_cursor?
381         # done early if we're already left of old cursor position
382         if new_cursor.x <= ideal_x
383                 return new_cursor
384         target_y = new_cursor.y
385         # search leftward, until we find the closest position
386         # new_cursor is the prev-most position we've checked
387         # prev_cursor is the older value, so it's not as prev as new_cursor
388         while new_cursor.x > ideal_x and new_cursor.y is target_y
389                 prev_cursor = new_cursor
390                 new_cursor = find_prev_cursor_position tree, new_cursor
391                 break unless new_cursor?
392         # move cursor to prev_cursor or new_cursor
393         if new_cursor?
394                 if new_cursor.y is target_y
395                         # both valid, and on the same line, use closest
396                         if (ideal_x - new_cursor.x) < (prev_cursor.x - ideal_x)
397                                 return new_cursor
398                         else
399                                 return prev_cursor
400                 else
401                         # new_cursor on wrong line, use prev_cursor
402                         return prev_cursor
403         else
404                 # can't go any further prev, use prev_cursor
405                 return prev_cursor
406
407 find_down_cursor_position = (tree, cursor, ideal_x) ->
408         new_cursor = cursor
409         # go next until we move on the y axis
410         while new_cursor.y <= cursor.y
411                 new_cursor = find_next_cursor_position tree, new_cursor
412                 return null unless new_cursor?
413         # done early if we're already right of old cursor position
414         if new_cursor.x >= ideal_x
415                 # this would be strange, but could happen due to runaround
416                 return new_cursor
417         target_y = new_cursor.y
418         # search rightward, until we find the closest position
419         # new_cursor is the next-most position we've checked
420         # prev_cursor is the older value, so it's not as next as new_cursor
421         while new_cursor.x < ideal_x and new_cursor.y is target_y
422                 prev_cursor = new_cursor
423                 new_cursor = find_next_cursor_position tree, new_cursor
424                 break unless new_cursor?
425         # move cursor to prev_cursor or new_cursor
426         if new_cursor?
427                 if new_cursor.y is target_y
428                         # both valid, and on the same line, use closest
429                         if (new_cursor.x - ideal_x) < (ideal_x - prev_cursor.x)
430                                 return new_cursor
431                         else
432                                 return prev_cursor
433                 else
434                         # new_cursor on wrong line, use prev_cursor
435                         return prev_cursor
436         else
437                 # can't go any further prev, use prev_cursor
438                 return prev_cursor
439
440 xy_to_cursor = (tree, xy) ->
441         for n in tree
442                 if n.type is 'tag' or n.type is 'text'
443                         bounds = get_el_bounds n.el
444                         continue if xy.x < bounds.x
445                         continue if xy.x > bounds.x + bounds.w
446                         continue if xy.y < bounds.y
447                         continue if xy.y > bounds.y + bounds.h
448                         if n.children.length
449                                 ret = xy_to_cursor n.children, xy
450                                 return ret if ret?
451                         if n.type is 'text'
452                                 # click is within bounding box that contains all text.
453                                 if n.text.length is 0
454                                         ret = new_cursor_position n: n, i: 0
455                                         return ret if ret?
456                                         continue
457                                 before = new_cursor_position n: n, i: 0
458                                 continue unless before?
459                                 after = new_cursor_position n: n, i: n.text.length
460                                 continue unless after?
461                                 if xy.y < before.y + before.h and xy.x < before.x
462                                         # console.log 'before first char on first line'
463                                         continue
464                                 if xy.y > after.y and xy.x > after.x
465                                         # console.log 'after last char on last line'
466                                         continue
467                                 if xy.y < before.y
468                                         console.log "Warning: click in text bounding box but above first line"
469                                         continue # above first line (runaround?)
470                                 if xy.y > after.y + after.h
471                                         console.log "Warning: click in text bounding box but below last line", xy.y, after.y, after.h
472                                         continue # below last line (shouldn't happen?)
473                                 while after.i - before.i > 1
474                                         guess_i = Math.round((before.i + after.i) / 2)
475                                         cur = new_cursor_position n: n, i: guess_i
476                                         unless cur?
477                                                 console.log "error: failed to find cursor pixel location for", n, guess_i
478                                                 before = null
479                                                 break
480                                         if xy.y < cur.y or (xy.y <= cur.y + cur.h and xy.x < cur.x)
481                                                 after = cur
482                                         else
483                                                 before = cur
484                                 continue unless before? # signals failure to find a cursor position
485                                 # which one is closest?
486                                 if Math.abs(before.x - xy.x) < Math.abs(after.x - xy.x)
487                                         return before
488                                 else
489                                         return after
490         return null
491
492 # browsers collapse these (html5 spec calls these "space characters")
493 is_space_code = (char_code) ->
494         switch char_code
495                 when 9, 10, 12, 13, 32
496                         return true
497         return false
498 is_space = (chr) ->
499         return is_space_code chr.charCodeAt 0
500
501 tree_remove_empty_text_nodes = (tree) ->
502         empties = []
503         traverse_tree tree, (n) ->
504                 if n.type is 'text'
505                         if n.text.length is 0
506                                 empties.unshift n
507                 return false # not done traversing
508         for n in empties
509                 # don't completely empty the tree
510                 if tree.length is 1
511                         if tree[0].type is 'text'
512                                 console.log "oop, leaving a blank node because it's the only thing"
513                                 return
514                 n.el.parentNode.removeChild n.el
515                 for c, i in n.parent.children
516                         if c is n
517                                 n.parent.children.splice i, 1
518                                 break
519
520 class PeachHTML5Editor
521         # Options: (all optional)
522         #   editor_id: "id" attribute for outer-most element created by/for editor
523         #   css_file: filename of a css file to style editable content
524         #   on_init: callback for when the editable content is in place
525         constructor: (in_el, options) ->
526                 @options = options ? {}
527                 @in_el = in_el
528                 @tree = null # array of Nodes, all editable content
529                 @tree_parent = null # @tree is this.children. .el might === @idoc.body
530                 @matting = []
531                 @init_1_called = false # when iframes have loaded
532                 @outer_iframe # iframe to hold editor
533                 @outer_idoc # "document" object for @outer_iframe
534                 @wrap2 = null # scrollbar is on this
535                 @wrap2_offset = null
536                 @wrap2_height = null # including padding
537                 @iframe = null # iframe to hold editable content
538                 @idoc = null # "document" object for @iframe
539                 @cursor = null
540                 @cursor_el = null
541                 @cursor_visible = false
542                 @cursor_ideal_x = null
543                 @poll_for_blur_timeout = null
544                 opt_fragment = @options.fragment ? true
545                 @parser_opts = {}
546                 if opt_fragment
547                         @parser_opts.fragment = 'body'
548
549                 @outer_iframe = domify document, iframe: {}
550                 outer_iframe_style = 'border: none !important; margin: 0 !important; padding: 0 !important; height: 100% !important; width: 100% !important;'
551                 if @options.editor_id?
552                         @outer_iframe.setAttribute 'id', @options.editor_id
553                 @outer_iframe.onload = =>
554                         @outer_idoc = @outer_iframe.contentDocument
555                         icss = domify @outer_idoc, style: children: [
556                                 domify @outer_idoc, text: css
557                         ]
558                         @outer_idoc.head.appendChild icss
559                         @iframe = domify @outer_idoc, iframe: sandbox: 'allow-same-origin allow-scripts'
560                         @iframe.onload = =>
561                                 @init_1()
562                         timeout 200, => # firefox never fires this onload
563                                 @init_1() unless @init_1_called
564                         @outer_idoc.body.appendChild(
565                                 domify @outer_idoc, div: id: 'wrap1', children: [
566                                         domify @outer_idoc, div: style: "position: absolute; top: 0; left: 1px; font-size: 10px", children: [ domify @outer_idoc, text: "Peach HTML5 Editor" ]
567                                         @wrap2 = domify @outer_idoc, div: id: 'wrap2', children: [
568                                                 domify @outer_idoc, div: id: 'wrap3', children: [
569                                                         @iframe
570                                                         @overlay = domify @outer_idoc, div: id: 'overlay'
571                                                 ]
572                                         ]
573                                 ]
574                         )
575                 outer_wrap = domify document, div: class: 'peach_html5_editor'
576                 @in_el.parentNode.appendChild outer_wrap
577                 outer_bounds = get_el_bounds outer_wrap
578                 if outer_bounds.w < 300
579                         outer_bounds.w = 300
580                 if outer_bounds.h < 300
581                         outer_bounds.h = 300
582                 outer_iframe_style += "width: #{outer_bounds.w}px; height: #{outer_bounds.h}px;"
583                 @outer_iframe.setAttribute 'style', outer_iframe_style
584                 css = @generate_outer_css w: outer_bounds.w, h: outer_bounds.h
585                 outer_wrap.appendChild @outer_iframe
586         init_1: -> # @iframe has loaded (but not it's css)
587                 @idoc = @iframe.contentDocument
588                 @init_1_called = true
589                 # chromium doesn't resolve relative urls as though they were at the same domain
590                 # so add a <base> tag
591                 @idoc.head.appendChild domify @idoc, base: href: this_url_sans_path()
592                 # don't let @iframe have scrollbars
593                 @idoc.head.appendChild domify @idoc, style: children: [domify @idoc, text: "body { overflow: hidden; }"]
594                 # load css file
595                 if @options.css_file
596                         istyle = domify @idoc, link: rel: 'stylesheet', href: @options.css_file
597                         istyle.onload = =>
598                                 @init_2()
599                         @idoc.head.appendChild istyle
600                 else
601                         @init_2()
602         init_2: -> # @iframe and it's css file(s) are ready
603                 @overlay.onclick = (e) =>
604                         @have_focus()
605                         return event_return e, @onclick e
606                 @overlay.ondoubleclick = (e) =>
607                         @have_focus()
608                         return event_return e, @ondoubleclick e
609                 @outer_idoc.body.onkeyup = (e) =>
610                         @have_focus()
611                         return event_return e, @onkeyup e
612                 @outer_idoc.body.onkeydown = (e) =>
613                         @have_focus()
614                         return event_return e, @onkeydown e
615                 @outer_idoc.body.onkeypress = (e) =>
616                         @have_focus()
617                         return event_return e, @onkeypress e
618                 @load_html @in_el.value
619                 if @options.on_init?
620                         @options.on_init()
621         generate_outer_css: (args) ->
622                 w = args.w ? 300
623                 h = args.h ? 300
624                 inner_padding = args.inner_padding ? overlay_padding
625                 frame_width = args.frame_width ? inner_padding
626                 occupy = (left, top = left, right = left, bottom = top) ->
627                         w -= left + right
628                         h -= top + bottom
629                         return Math.max(left, top, right, bottom)
630                 ret = ''
631                 ret += 'body {'
632                 ret +=     'margin: 0;'
633                 ret +=     'padding: 0;'
634                 ret +=     'color: black;'
635                 ret +=     'background: white;'
636                 ret += '}'
637                 ret += '#wrap1 {'
638                 ret +=     "border: #{occupy 1}px solid black;"
639                 ret +=     "padding: #{occupy frame_width}px;"
640                 ret += '}'
641                 ret += '#wrap2 {'
642                 ret +=     "border: #{occupy 1}px solid black;"
643                 @wrap2_height = h # including padding because padding scrolls
644                 ret +=     "padding: #{occupy inner_padding}px;"
645                 ret +=     "padding-right: #{inner_padding + occupy 0, 0, 15, 0}px;" # for scroll bar
646                 ret +=     "width: #{w}px;"
647                 ret +=     "height: #{h}px;"
648                 ret +=     'overflow-x: hidden;'
649                 ret +=     'overflow-y: scroll;'
650                 ret += '}'
651                 ret += '#wrap3 {'
652                 ret +=     'position: relative;'
653                 ret +=     "width: #{w}px;"
654                 ret +=     "min-height: #{h}px;"
655                 ret += '}'
656                 ret += 'iframe {'
657                 ret +=     'box-sizing: border-box;'
658                 ret +=     'margin: 0;'
659                 ret +=     'border: none;'
660                 ret +=     'padding: 0;'
661                 ret +=     "width: #{w}px;"
662                 #ret +=     "height: #{h}px;" # height auto-set when content set/changed
663                 ret +=     '-ms-user-select: none;'
664                 ret +=     '-webkit-user-select: none;'
665                 ret +=     '-moz-user-select: none;'
666                 ret +=     'user-select: none;'
667                 ret += '}'
668                 ret += '#overlay {'
669                 ret +=     'position: absolute;'
670                 ret +=     "left: -#{inner_padding}px;"
671                 ret +=     "top: -#{inner_padding}px;"
672                 ret +=     "right: -#{inner_padding}px;"
673                 ret +=     "bottom: -#{inner_padding}px;"
674                 ret +=     'overflow: hidden;'
675                 ret += '}'
676                 ret += '.lightbox {'
677                 ret +=     'position: absolute;'
678                 ret +=     'background: rgba(100,100,100,0.2);'
679                 ret += '}'
680                 ret += '#cursor {'
681                 ret +=     'position: absolute;'
682                 ret +=     'width: 2px;'
683                 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));'
684                 ret +=     'background-size: 200% 200%;'
685                 ret +=     '-webkit-animation: blink 1s linear normal infinite;'
686                 ret +=     'animation: blink 1s linear normal infinite;'
687                 ret += '}'
688                 ret += '@-webkit-keyframes blink {'
689                 ret +=     '0%{background-position:0% 0%}'
690                 ret +=     '100%{background-position:0% -100%}'
691                 ret += '}'
692                 ret += '@keyframes blink { '
693                 ret +=     '0%{background-position:0% 0%}'
694                 ret +=     '100%{background-position:0% -100%}'
695                 ret += '}'
696                 ret += '.ann_box {'
697                 ret +=     'z-index: 5;'
698                 ret +=     'position: absolute;'
699                 ret +=     'border: 1px solid rgba(0,0,0,0.1);'
700                 ret +=     'outline: 1px solid rgba(255,255,255,0.1);' # in case there's a black background
701                 ret += '}'
702                 ret += '.ann_tag {'
703                 ret +=     'z-index: 10;'
704                 ret +=     'position: absolute;'
705                 ret +=     'font-size: 8px;'
706                 ret +=     'white-space: pre;'
707                 ret +=     'background: rgba(255,255,255,0.4);'
708                 ret +=     '-ms-user-select: none;'
709                 ret +=     '-webkit-user-select: none;'
710                 ret +=     '-moz-user-select: none;'
711                 ret +=     'user-select: none;'
712                 ret += '}'
713                 return ret
714         overlay_event_to_inner_xy: (e) ->
715                 unless @wrap2_offset?
716                         @wrap2_offset = get_el_bounds @wrap2
717                 x = e.pageX - overlay_padding
718                 y = e.pageY - overlay_padding + @wrap2.scrollTop
719                 return x: x - @wrap2_offset.x, y: y - @wrap2_offset.y
720         onclick: (e) ->
721                 xy = @overlay_event_to_inner_xy e
722                 new_cursor = xy_to_cursor @tree, xy
723                 if new_cursor?
724                         @move_cursor new_cursor
725                 else
726                         @kill_cursor()
727                 return false
728         ondoubleclick: (e) ->
729                 return false
730         onkeyup: (e) ->
731                 return if e.ctrlKey
732                 return false if ignore_key_codes[e.keyCode]?
733                 #return false if control_key_codes[e.keyCode]?
734         onkeydown: (e) ->
735                 return if e.ctrlKey
736                 return false if ignore_key_codes[e.keyCode]?
737                 #return false if control_key_codes[e.keyCode]?
738                 switch e.keyCode
739                         when KEY_LEFT
740                                 if @cursor?
741                                         new_cursor = find_prev_cursor_position @tree, @cursor
742                                 else
743                                         new_cursor = first_cursor_position @tree
744                                 if new_cursor?
745                                         @move_cursor new_cursor
746                                 return false
747                         when KEY_RIGHT
748                                 if @cursor?
749                                         new_cursor = find_next_cursor_position @tree, @cursor
750                                 else
751                                         new_cursor = last_cursor_position @tree
752                                 if new_cursor?
753                                         @move_cursor new_cursor
754                                 return false
755                         when KEY_UP
756                                 if @cursor?
757                                         new_cursor = find_up_cursor_position @tree, @cursor, @cursor_ideal_x
758                                         if new_cursor?
759                                                 saved_ideal_x = @cursor_ideal_x
760                                                 @move_cursor new_cursor
761                                                 @cursor_ideal_x = saved_ideal_x
762                                 else
763                                         # move cursor to first position in document
764                                         new_cursor = first_cursor_position @tree
765                                         if new_cursor?
766                                                 @move_cursor new_cursor
767                                 return false
768                         when KEY_DOWN
769                                 if @cursor?
770                                         new_cursor = find_down_cursor_position @tree, @cursor, @cursor_ideal_x
771                                         if new_cursor?
772                                                 saved_ideal_x = @cursor_ideal_x
773                                                 @move_cursor new_cursor
774                                                 @cursor_ideal_x = saved_ideal_x
775                                 else
776                                         # move cursor to first position in document
777                                         new_cursor = last_cursor_position @tree
778                                         if new_cursor?
779                                                 @move_cursor new_cursor
780                                 return false
781                         when KEY_END
782                                 new_cursor = last_cursor_position @tree
783                                 if new_cursor?
784                                         @move_cursor new_cursor
785                                 return false
786                         when KEY_BACKSPACE
787                                 @on_key_backspace e
788                                 return false
789                         when KEY_DELETE
790                                 return false unless @cursor?
791                                 new_cursor = find_next_cursor_position @tree, n: @cursor.n, i: @cursor.i
792                                 # try moving cursor right and then running backspace code
793                                 # TODO replace this hack with a real implementation
794                                 if new_cursor?
795                                         # try to detect common case where cursor goes inside an block,
796                                         # but doesn't pass a character (and advance one more in that case)
797                                         if new_cursor.n isnt @cursor.n and new_cursor.i is 0
798                                                 if new_cursor.n.type is 'text' and new_cursor.n.text.length > 0
799                                                         if new_cursor.n.parent?
800                                                                 unless @is_display_block new_cursor.n.parent
801                                                                         # FIXME should test run sibling
802                                                                         new_cursor = new_cursor_position n: new_cursor.n, i: new_cursor.i + 1
803                                 if new_cursor?
804                                         if new_cursor.n isnt @cursor.n or new_cursor.i isnt @cursor.i
805                                                 @move_cursor new_cursor
806                                                 @on_key_backspace e
807                                 return false
808                         when KEY_ENTER
809                                 @on_key_enter e
810                                 return false
811                         when KEY_ESCAPE
812                                 @kill_cursor()
813                                 return false
814                         when KEY_HOME
815                                 new_cursor = first_cursor_position @tree
816                                 if new_cursor?
817                                         @move_cursor new_cursor
818                                 return false
819                         when KEY_INSERT
820                                 return false
821                         when KEY_PAGE_UP
822                                 @on_page_up_key e
823                                 return false
824                         when KEY_PAGE_DOWN
825                                 @on_page_down_key e
826                                 return false
827                         when KEY_TAB
828                                 return false
829         onkeypress: (e) ->
830                 return if e.ctrlKey
831                 return false if ignore_key_codes[e.keyCode]?
832                 char = e.charCode ? e.keyCode
833                 if char and @cursor?
834                         char = String.fromCharCode char
835                         @insert_character @cursor.n, @cursor.i, char
836                         @text_cleanup @cursor.n
837                         @changed()
838                         new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i + 1
839                         if new_cursor
840                                 @move_cursor new_cursor
841                         else
842                                 console.log "ERROR: couldn't find cursor position after insert"
843                                 @kill_cursor()
844                 return false
845         on_key_enter: (e) -> # enter key pressed
846                 return unless @cursor_visible
847                 cur_block = @cursor.n
848                 loop
849                         if cur_block.type is 'tag'
850                                 if is_display_block cur_block.el
851                                         break
852                         return unless cur_block.parent?
853                         cur_block = cur_block.parent
854                 # find array to insert new element into
855                 if cur_block.parent is @tree_parent # top-level
856                         parent_el = @idoc.body
857                         pc = @tree
858                 else
859                         parent_el = cur_block.parent.el
860                         pc = cur_block.parent.children
861                 # find index of current block in its parent
862                 for n, i in pc
863                         break if n is cur_block
864                 i += 1 # we want to be after it
865                 if i < pc.length
866                         before = pc[i].el
867                 else
868                         before = null
869                 # TODO if content after cursor
870                 #       TODO new block is empty
871                 new_text = new peach_parser.Node 'text', text: ' '
872                 new_node = new peach_parser.Node 'tag', name: 'p', parent: cur_block.parent, attrs: {style: 'white-space: pre-wrap'}, children: [new_text]
873                 new_text.parent = new_node
874                 new_text.el = domify @idoc, text: ' '
875                 new_node.el = domify @idoc, p: style: 'white-space: pre-wrap', children: [new_text.el]
876                 pc.splice i, 0, new_node
877                 parent_el.insertBefore new_node.el, before
878                 @changed()
879                 new_cursor = new_cursor_position n: new_text, i: 0
880                 throw 'bork bork' unless new_cursor?
881                 @move_cursor new_cursor
882                 # TODO move content past cursor into this new block
883         # unlike the global function, this takes a Node, not an element
884         is_display_block: (n) ->
885                 # TODO stop calling global function, merge it into here, use iframe's window object
886                 return false unless n.type is 'tag'
887                 return is_display_block n.el
888         find_block_parent: (n) ->
889                 loop
890                         n = n.parent
891                         return null unless n?
892                         return n if @is_display_block n
893                         return n if n is @tree_parent
894                 return null
895         # return a flat array of nodes (text, <br>, and later also inline-block)
896         # that are flowing/wrapping together. n can be the containing block, or any
897         # element inside it.
898         get_text_run: (n) ->
899                 ret = []
900                 if @is_display_block n
901                         block = n
902                 else
903                         block = @find_block_parent n
904                         return ret unless block?
905                 traverse_tree block.children, (n) =>
906                         if n.type is 'text'
907                                 ret.push n
908                         else if n.type is 'tag'
909                                 if n.name is 'br'
910                                         ret.push n
911                                 else
912                                         disp = @computed_style n
913                                         if disp is 'inline-block'
914                                                 ret.push n
915                         return false # not done traversing
916                 return ret
917         node_is_decendant: (young, old) ->
918                 while young? and young != @tree_parent
919                         return true if young is old
920                         young = young.parent
921                 return false
922         # helper for on_key_backspace
923         _merge_left: (state) ->
924                 # the node prev to n was not prev to it a moment ago, merge with it if reasonable
925                 pi = state.n.parent.children.indexOf(state.n)
926                 if pi > 0
927                         prev = state.n.parent.children[pi - 1]
928                         if prev.type is 'text'
929                                 state.i = prev.text.length
930                                 prev.text = prev.el.textContent = prev.text + state.n.text
931                                 @remove_node state.n
932                                 state.n = prev
933                                 state.changed = true
934                                 state.moved_cursor = true
935                 # else # TODO merge possible consecutive matching inline tags at @cursor
936                 return state
937         # helper for on_key_backspace
938         # remove n from the dom, also remove its inline parents that are emptied by removing n
939         _backspace_node_helper: (n, run = @get_text_run(n), run_i = run.indexOf(n)) ->
940                 block = @find_block_parent n
941                 # delete text node
942                 @remove_node n
943                 # delete any inline parents
944                 n = n.parent
945                 while n? and n isnt block
946                         # bail if the previous node in this run is also inside the same parent
947                         if run_i > 0
948                                 break if @node_is_decendant run[run_i - 1], n
949                         # bail if the next node in this run is also inside the same parent
950                         if run_i + 1 < run.length
951                                 break if @node_is_decendant run[run_i + 1], n
952                         # move any sibling nodes to parent. These nodes are not in the text run
953                         while n.children.length > 0
954                                 @move_node n.children[0], n.parent, n
955                         # remove (now completely empty) inline parent
956                         @remove_node n
957                         # proceed to outer parent
958                         n = n.parent
959                 return
960         on_key_backspace: (e) ->
961                 return unless @cursor?
962                 new_cursor = null
963                 run = null
964                 changed = true
965                 if @cursor.i is 0 # cursor is at start of text node
966                         run ?= @get_text_run @cursor.n
967                         run_i = run.indexOf(@cursor.n)
968                         if run_i is 0 # if at start of text run
969                                 block = @find_block_parent @cursor.n
970                                 prev_cursor = find_prev_cursor_position @tree, n: @cursor.n, i: 0
971                                 if prev_cursor is null # if in first text run of document
972                                         # do nothing (there's nothing text-like to the left of the cursor)
973                                         return
974                                 # else merge with prev/outer text run
975                                 pcb = @find_block_parent prev_cursor.n
976                                 while block.children.length > 0
977                                         @move_node block.children[0], pcb
978                                 @remove_node block
979                                 # merge possible consecutive text nodes at @cursor
980                                 merge_state = n: @cursor.n
981                                 @_merge_left merge_state
982                                 @text_cleanup merge_state.n
983                                 new_cursor = new_cursor_position n: merge_state.n, i: merge_state.i
984                         else # at start of text node, but not start of text run
985                                 prev = run[run_i - 1]
986                                 if prev.type is 'text' # if previous in text run is text
987                                         if prev.text.length is 1 # if emptying prev (in text run)
988                                                 @_backspace_node_helper prev, run, run_i
989                                                 merge_state = n: @cursor.n, i: @cursor.i
990                                                 @_merge_left merge_state
991                                                 @text_cleanup merge_state.n
992                                                 new_cursor = new_cursor_position n: merge_state.n, i: merge_state.i
993                                         else # prev in run is text with muliple chars
994                                                 # delete last character in prev
995                                                 prev.text = prev.text.substr(0, prev.text.length - 1)
996                                                 prev.el.textContent = prev.text
997                                                 @text_cleanup @cursor.n
998                                                 new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i
999                                 else if prev.name is 'br' or prev.name is 'hr'
1000                                         @_backspace_node_helper prev, run, run_i
1001                                         merge_state = n: @cursor.n, i: @cursor.i
1002                                         @_merge_left merge_state
1003                                         @text_cleanup merge_state.n
1004                                         new_cursor = new_cursor_position n: merge_state.n, i: merge_state.i
1005                                 # FIXME implement this:
1006                                 # else # if prev (in run) is inline-block
1007                                         # if that inline-block has text in it
1008                                                 # delete last char in prev inlineblock
1009                                                 # if that empties it
1010                                                         # delete it
1011                                                         # merge left
1012                                                 # else
1013                                                         # move cursor inside
1014                                         # else
1015                                                 # delete prev (inline) block
1016                                                 # merge left
1017                                         # auto-delete this @cursor.parent(s) if this empties them
1018                 else # cursor is not at start of text node
1019                         run ?= @get_text_run @cursor.n
1020                         if @cursor.n.text.length is 1 # if emptying text node
1021                                 if run.length is 1 # if emptying text run (of text/br/hr/inline-block)
1022                                         # remove inline-parents of @cursor.n
1023                                         block = @find_block_parent @cursor.n
1024                                         changed = false
1025                                         n = @cursor.n.parent
1026                                         # note: this doesn't use _backspace_node_helper because:
1027                                         # 1. we don't want to delete the target node (we're replacing it's contents)
1028                                         # 2. we want to track whether anything was removed
1029                                         # 3. we know already know there's no other text from this run anywhere
1030                                         while n and n isnt block
1031                                                 changed = true
1032                                                 while n.children.length > 0
1033                                                         @move_node n.children[0], n.parent, n
1034                                                 @remove_node n
1035                                                 n = n.parent
1036                                         # replace @cursor.n with a single (preserved) space
1037                                         if @cursor.n.text != ' '
1038                                                 changed = true
1039                                                 @cursor.n.text = @cursor.n.el.textContent = ' '
1040                                         if changed
1041                                                 @text_cleanup @cursor.n
1042                                         # place the cursor to the left of that space
1043                                         new_cursor = new_cursor_position n: @cursor.n, i: 0
1044                                 else # emptying a text node (but not a whole text run)
1045                                         # figure out where cursor should land
1046                                         block = @find_block_parent @cursor.n
1047                                         new_cursor = find_prev_cursor_position @tree, n: @cursor.n, i: 0
1048                                         ncb = @find_block_parent new_cursor.n
1049                                         if ncb isnt block
1050                                                 new_cursor = find_next_cursor_position @tree, n: @cursor.n, i: 1
1051                                         # delete text node and cleanup emptied parents
1052                                         run_i = run.indexOf @cursor.n
1053                                         @_backspace_node_helper @cursor.n, run, run_i
1054                                         # see if new adjacent siblings should merge
1055                                         # TODO make smarter
1056                                         if run_i > 0 and run_i + 1 < run.length
1057                                                 if run[run_i - 1].type is 'text' and run[run_i + 1].type is 'text'
1058                                                         merge_state = n: run[run_i + 1]
1059                                                         @_merge_left merge_state
1060                                                         if merge_state.moved_cursor
1061                                                                 new_cursor = merge_state
1062                                         # update whitespace preservation
1063                                         @text_cleanup(block)
1064                                         # update cursor x/y in case things moved around
1065                                         if new_cursor?
1066                                                 if new_cursor.n.el.parentNode # still in dom after cleanup
1067                                                         new_cursor = new_cursor_position n: new_cursor.n, i: new_cursor.i
1068                                                 else
1069                                                         new_cursor = null
1070                         else # there's a char left of cursor that we can delete without emptying anything
1071                                 # delete character
1072                                 need_text_cleanup = true
1073                                 if @cursor.i > 1 and @cursor.i < @cursor.n.text.length
1074                                         pre = @cursor.n.text.substr(@cursor.i - 2, 3)
1075                                         post = pre.charAt(0) + pre.charAt(2)
1076                                         if str_has_ws_run(pre) is str_has_ws_run(post)
1077                                                 need_text_cleanup = false
1078                                 @remove_character(@cursor.n, @cursor.i - 1)
1079                                 # call text_cleanup if whe created/removed a whitespace run
1080                                 if need_text_cleanup
1081                                         @text_cleanup @cursor.n
1082                                 new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i - 1
1083                 # mark document changed and move the cursor
1084                 if changed?
1085                         @changed()
1086                 if new_cursor?
1087                         @move_cursor new_cursor
1088                 else
1089                         @kill_cursor()
1090                 return
1091         on_page_up_key: (e) ->
1092                 if @wrap2.scrollTop is 0
1093                         return unless @cursor?
1094                         new_cursor = first_cursor_position @tree
1095                         if new_cursor?
1096                                 if new_cursor.n isnt @cursor.n or new_cursor.i isnt @cursor.i
1097                                         @move_cursor new_cursor
1098                         return
1099                 if @cursor?
1100                         screen_y = @cursor.y - @wrap2.scrollTop
1101                 scroll_amount = @wrap2_height - breathing_room
1102                 @wrap2.scrollTop = Math.max 0, @wrap2.scrollTop - scroll_amount
1103                 if @cursor?
1104                         @move_cursor_into_view screen_y + @wrap2.scrollTop
1105         on_page_down_key: (e) ->
1106                 lowest_scrollpos = @wrap2.scrollHeight - @wrap2_height
1107                 if @wrap2.scrollTop is lowest_scrollpos
1108                         return unless @cursor?
1109                         new_cursor = last_cursor_position @tree
1110                         if new_cursor?
1111                                 if new_cursor.n isnt @cursor.n or new_cursor.i isnt @cursor.i
1112                                         @move_cursor new_cursor
1113                         return
1114                 if @cursor?
1115                         screen_y = @cursor.y - @wrap2.scrollTop
1116                 scroll_amount = @wrap2_height - breathing_room
1117                 @wrap2.scrollTop = Math.min lowest_scrollpos, @wrap2.scrollTop + scroll_amount
1118                 if @cursor?
1119                         @move_cursor_into_view screen_y + @wrap2.scrollTop
1120                 return
1121         move_cursor_into_view: (y_target) ->
1122                 return if y_target is @cursor.y
1123                 was = @cursor
1124                 y_min = @wrap2.scrollTop
1125                 unless @wrap2.scrollTop is 0
1126                         y_min += breathing_room
1127                 y_max = @wrap2.scrollTop + @wrap2_height
1128                 unless @wrap2.scrollTop is @wrap2.scrollHeight - @wrap2_height # downmost
1129                         y_max -= breathing_room
1130                 y_target = Math.min y_target, y_max
1131                 y_target = Math.max y_target, y_min
1132                 if y_target < @cursor.y
1133                         finder = find_up_cursor_position
1134                         far_enough = (cur, target_y) ->
1135                                 return cur.y + cur.h <= target_y
1136                 else
1137                         finder = find_down_cursor_position
1138                         far_enough = (cur, y_target) ->
1139                                 return cur.y >= y_target
1140                 loop
1141                         cur = finder @tree, was, @cursor_ideal_x
1142                         break unless cur?
1143                         break if far_enough cur, y_target
1144                         was = cur
1145                 if was is @cursor
1146                         was = null
1147                 if was?
1148                         if was.y + was.h > y_max
1149                                 was = null
1150                         else if was.y < y_min
1151                                 was = null
1152                 if cur?
1153                         if cur.y + cur.h > y_max
1154                                 cur = null
1155                         else if cur.y < y_min
1156                                 cur = null
1157                 if cur? and was?
1158                         # both valid, pick best
1159                         if cur.y < y_min
1160                                 new_cursor = was
1161                         else if was.y + was.h > y_max
1162                                 new_cursor = cur
1163                         else if cur.y - y_target < y_target - was.y
1164                                 new_cursor = cur
1165                         else
1166                                 new_cursor = was
1167                 else
1168                         new_cursor = was ? cur
1169                 if new_cursor?
1170                         saved_ideal_x = @cursor_ideal_x
1171                         @move_cursor new_cursor
1172                         @cursor_ideal_x = saved_ideal_x
1173                 return
1174         clear_dom: -> # remove all the editable content (and cursor, overlays, etc)
1175                 while @idoc.body.childNodes.length
1176                         @idoc.body.removeChild @idoc.body.childNodes[0]
1177                 @kill_cursor()
1178                 return
1179         load_html: (html) ->
1180                 @tree = peach_parser.parse html, @parser_opts
1181                 if !@tree[0]?.parent
1182                         @tree = peach_parser.parse '<p style="white-space: pre-wrap"> </p>', @parser_opts
1183                 @tree_parent = @tree[0]?.parent
1184                 @tree_parent.el = @idoc.body
1185                 @clear_dom()
1186                 instantiate_tree @tree, @tree_parent.el
1187                 @collapse_whitespace @tree
1188                 @changed()
1189         changed: ->
1190                 @in_el.onchange = null
1191                 @in_el.value = @pretty_html @tree
1192                 @in_el.onchange = =>
1193                         @load_html @in_el.value
1194                 @adjust_iframe_height()
1195         adjust_iframe_height: ->
1196                 s = @wrap2.scrollTop
1197                 # when the content gets shorter, the idoc's body tag will continue to
1198                 # report the old (too big) height in Chrome. The workaround is to
1199                 # shrink the iframe before the content height:
1200                 @iframe.style.height = "10px"
1201                 h = parseInt(@idoc.body.scrollHeight, 10)
1202                 @iframe.style.height = "#{h}px"
1203                 @wrap2.scrollTop = s
1204         # true if n is text node with only one caracter, and the only child of a tag
1205         is_only_char_in_tag: (n, i) ->
1206                 return false unless n.type is 'text'
1207                 return false unless n.text.length is 1
1208                 return false if n.parent is @tree_parent
1209                 return false unless n.parent.children.length is 1
1210                 return true
1211         # true if n is text node with just a space in it, and the only child of a tag
1212         is_lone_space: (n, i) ->
1213                 return false unless n.type is 'text'
1214                 return false unless n.text is ' '
1215                 return false if n.parent is @tree_parent
1216                 return false unless n.parent.children.length is 1
1217                 return true
1218         # detect special case: typing before a space that's the only thing in a block/doc
1219         # reason: enter key creates blocks with just a space in them
1220         insert_should_replace: (n, i) ->
1221                 return false unless i is 0
1222                 return false unless n.text is ' '
1223                 return true if n.parent is @tree_parent
1224                 if n.parent.children.length is 1
1225                         if n.parent.children[0] is n
1226                                 # n is only child
1227                                 return true
1228                 return false
1229         # after calling this, you MUST call changed() and text_cleanup()
1230         insert_character: (n, i, char) ->
1231                 return if n.parent is @tree_parent # FIXME implement text nodes at top level
1232                 # insert the character
1233                 if @insert_should_replace n, i
1234                         n.text = char
1235                 else if i is 0
1236                         n.text = char + n.text
1237                 else if i is n.text.length
1238                         # replace the space
1239                         n.text += char
1240                 else
1241                         n.text =
1242                                 n.text.substr(0, i) +
1243                                 char +
1244                                 n.text.substr(i)
1245                 n.el.nodeValue = n.text
1246         # WARNING: after calling this, you MUST call changed() and text_cleanup()
1247         remove_character: (n, i) ->
1248                 n.text = n.text.substr(0, i) + n.text.substr(i + 1)
1249                 n.el.nodeValue = n.text
1250         computed_style: (n, prop) ->
1251                 if n.type is 'text'
1252                         n = n.parent
1253                 style = @iframe.contentWindow.getComputedStyle n.el, null
1254                 return style.getPropertyValue prop
1255         # returns the new white-space value that will preserve spaces for node n
1256         preserve_space: (n, ideal_target) ->
1257                 if n.type is 'text'
1258                         target = n.parent
1259                 else
1260                         target = n
1261                 while target isnt ideal_target and not target.el.style.whiteSpace
1262                         unless target?
1263                                 console.log "bug #967123"
1264                                 return
1265                         target = target.parent
1266                 ws = ws_props[target.el.style.whiteSpace]?.to_preserve
1267                 ws ?= 'pre-wrap'
1268                 target.el.style.whiteSpace = ws
1269                 @update_style_from_el target
1270                 return ws
1271         update_style_from_el: (n) ->
1272                 style = n.el.getAttribute 'style'
1273                 if style?
1274                         n.attrs.style = style
1275                 else
1276                         if n.attrs.style?
1277                                 delete n.attrs.style
1278         # remove whitespace that would be trimmed
1279         # replace whitespace that would collapse with a single space
1280         # FIXME remove whitespace from after <br> (but not before)
1281         # FIXME rewrite to
1282         #     check computed white-space prop on txt parents
1283         #     batch replace txt node contents (ie don't loop for each char)
1284         collapse_whitespace: (tree = @tree) ->
1285                 prev = cur = next = null
1286                 prev_i = cur_i = next_i = 0
1287                 prev_pos = pos = next_pos = null
1288                 prev_px = cur_px = next_px = null
1289                 first = true
1290                 removed_char = null
1291
1292                 tree_remove_empty_text_nodes(tree)
1293
1294                 iterate = (tree, cb) ->
1295                         for n in tree
1296                                 if n.type is 'text'
1297                                         i = 0
1298                                         while i < n.text.length # don't foreach, cb might remove chars
1299                                                 advance = cb n, i
1300                                                 if advance
1301                                                         i += 1
1302                                 if n.type is 'tag'
1303                                         block = is_display_block n.el
1304                                         if block
1305                                                 cb null
1306                                         if n.children.length > 0
1307                                                 iterate n.children, cb
1308                                         if block
1309                                                 cb null
1310                 # remove cur char
1311                 remove = (undo) ->
1312                         if undo
1313                                 cur.el.textContent = cur.text = (cur.text.substr 0, cur_i) + removed_char + (cur.text.substr cur_i)
1314                                 if next is cur # in same text node
1315                                         next_i += 1
1316                                 return -1
1317                         else
1318                                 removed_char = cur.text.charAt(cur_i)
1319                                 cur.el.textContent = cur.text = (cur.text.substr 0, cur_i) + (cur.text.substr cur_i + 1)
1320                                 if next is cur # in same text node
1321                                         if next_i is 0
1322                                                 throw "how is this possible?"
1323                                         next_i -= 1
1324                                 return 1
1325                 replace_with_space = (undo) ->
1326                         if undo
1327                                 cur.text = (cur.text.substr 0, cur_i) + removed_char + (cur.text.substr cur_i + 1)
1328                                 cur.el.textContent = cur.text
1329                         else
1330                                 removed_char = cur.text.charAt(cur_i)
1331                                 if removed_char isnt ' '
1332                                         cur.text = (cur.text.substr 0, cur_i) + ' ' + (cur.text.substr cur_i + 1)
1333                                         cur.el.textContent = cur.text
1334                         return 0
1335                 # return true if cur was removed from the dom (ie re-use same prev)
1336                 operate = ->
1337                         # cur definitately set
1338                         # prev and/or next might be null, indicating the start/end of a display:block
1339                         return false unless is_space_code cur.text.charCodeAt cur_i
1340                         fixers = [remove, replace_with_space]
1341                         # check for common case: single whitespace surrounded by non-whitespace chars
1342                         if prev? and next?
1343                                 unless (is_space_code prev.text.charCodeAt prev_i) or (is_space_code next.text.charCodeAt next_i)
1344                                         dbg = cur.text.charCodeAt cur_i
1345                                         if cur.text.charAt(cur_i) is ' ' # perens required
1346                                                 # single space can't collapse, doesn't need fixin'
1347                                                 return false
1348                                         else
1349                                                 # tab, newline, etc, can't collapse, but maybe should be replaced
1350                                                 fixers = [replace_with_space]
1351                         bounds = text_range_bounds cur.el, cur_i, cur_i + 1
1352                         # consistent cases:
1353                         # 1. zero rects returned by getClientRects() means collapsed space
1354                         if bounds is null
1355                                 return remove()
1356                         # 2. width greater than zero means visible space
1357                         if bounds.w > 0
1358                                 # has bounds, don't try removing
1359                                 fixers = [replace_with_space]
1360                         # now the weird edge cases...
1361                         #
1362                         # firefox and chromium both report zero width for characters at the end
1363                         # of a line where the text wraps (automatically, due to word-wrap) to
1364                         # the next line. These do not appear to be distinguishable from
1365                         # collapsed spaces via the range/bounds api, so...
1366                         #
1367                         # remove it from the dom, and if prev or next moves, put it back.
1368                         #
1369                         # this block (try changing it, put it back if something moves) is also
1370                         # used on collapsable whitespace characters besides space. In this case
1371                         # the character is replaced with a normal space character instead of
1372                         # removed
1373                         if prev? and not prev_px?
1374                                 prev_px = new_cursor_position n: prev, i: prev_i
1375                         if next? and not next_px?
1376                                 next_px = new_cursor_position n: next, i: next_i
1377                         #if prev is null and next is null
1378                         #       parent_px = cur.parent.el.getBoundingClientRect()
1379                         undo_arg = true # just for readabality
1380                         removed = 0
1381                         for fixer in fixers
1382                                 break if removed > 0
1383                                 removed += fixer()
1384                                 need_undo = false
1385                                 if prev?
1386                                         if prev_px?
1387                                                 new_prev_px = new_cursor_position n: prev, i: prev_i
1388                                                 if new_prev_px?
1389                                                         if new_prev_px.x isnt prev_px.x or new_prev_px.y isnt prev_px.y
1390                                                                 need_undo = true
1391                                                 else
1392                                                         need_undo = true
1393                                         else
1394                                                 console.log "this shouldn't happen, we remove spaces that don't locate"
1395                                 if next? and not need_undo
1396                                         if next_px?
1397                                                 new_next_px = new_cursor_position n: next, i: next_i
1398                                                 if new_next_px?
1399                                                         if new_next_px.x isnt next_px.x or new_next_px.y isnt next_px.y
1400                                                                 need_undo = true
1401                                                 else
1402                                                         need_undo = true
1403                                         #else
1404                                         #       console.log "removing space becase space after it is collapsed"
1405                                 if need_undo
1406                                         removed += fixer undo_arg
1407                         if removed > 0
1408                                 return true
1409                         else
1410                                 return false
1411                 # pass null at start/end of display:block
1412                 queue = (n, i) ->
1413                         next = n
1414                         next_i = i
1415                         next_px = null
1416                         advance = true
1417                         if cur?
1418                                 removed = operate()
1419                                 # don't advance (to the next character next time) if we removed a
1420                                 # character from the same text node as ``next``, because doing so
1421                                 # renumbers the indexes in that string
1422                                 if removed and cur is next
1423                                         advance = false
1424                         else
1425                                 removed = false
1426                         unless removed
1427                                 prev = cur
1428                                 prev_i = cur_i
1429                                 prev_px = cur_px
1430                         cur = next
1431                         cur_i = next_i
1432                         cur_px = next_px
1433                         return advance
1434                 queue null
1435                 iterate tree, queue
1436                 queue null
1437
1438                 tree_remove_empty_text_nodes(tree)
1439                 return
1440         # call this after you insert or remove inline nodes. It will:
1441         #    merge consecutive text nodes
1442         #    remove empty text nodes
1443         #    adjust white-space property
1444         # note: this assumes that all whitespace in text nodes should be displayed
1445         # (ie not collapse or be trimmed) and will change the white-space property
1446         # as needed to achieve this.
1447         text_cleanup: (n) ->
1448                 if @is_display_block n
1449                         block = n
1450                 else
1451                         block = @find_block_parent n
1452                         return unless block?
1453                 run = @get_text_run block
1454                 return unless run?
1455                 # merge consecutive text nodes
1456                 if run.length > 1
1457                         i = 1
1458                         prev = run[0]
1459                         while i < run.length
1460                                 n = run[i]
1461                                 if prev.type is 'text' and n.type is 'text'
1462                                         if prev.parent is n.parent
1463                                                 prev_i = n.parent.children.indexOf prev
1464                                                 n_i =    n.parent.children.indexOf n
1465                                                 if n_i is prev_i + 1
1466                                                         prev.text = prev.text + n.text
1467                                                         prev.el.textContent = prev.text
1468                                                         @remove_node n
1469                                                         run.splice i, 1
1470                                                         continue # don't increment i or change prev
1471                                 i += 1
1472                                 prev = n
1473                 # remove empty text nodes
1474                 i = 0
1475                 while i < run.length
1476                         n = run[i]
1477                         if n.type is 'text'
1478                                 if n.text is ''
1479                                         @remove_node n
1480                                         # FIXME maybe remove parents recursively if this makes them empty
1481                                         run.splice i, 1
1482                                         continue # don't increment i
1483                         i += 1
1484                 # note: inline tags can have white-space:pre-line/etc
1485                 # note: inline-blocks have their whitespace collapsed independantly of outer run
1486                 # note: inline-blocks are treated like non-whitespace char even if empty
1487                 if block.el.style.whiteSpace?
1488                         ws = block.el.style.whiteSpace
1489                         if ws_props[ws]
1490                                 if ws_props[ws].space
1491                                         if ws_props[ws].to_collapse is 'normal'
1492                                                 block.el.style.whiteSpace = null
1493                                         else
1494                                                 block.el.style.whiteSpace = ws_props[ws].to_collapse
1495                                         @update_style_from_el block
1496                 # note: space after <br> colapses, but not space before
1497                 # check for spaces that would collapse without help
1498                 eats_start_sp = true # if the next node starts with space it collapses (unless pre)
1499                 prev = null
1500                 for n in run
1501                         if n.type is 'tag'
1502                                 if n.name is 'br'
1503                                         eats_start_sp = true
1504                                 else
1505                                         eats_start_sp = false
1506                         else # TEXT
1507                                 need_preserve = false
1508                                 if n.type isnt 'text'
1509                                         console.log "bug #232308"
1510                                         return
1511                                 if eats_start_sp
1512                                         if is_space_code n.text.charCodeAt 0
1513                                                 need_preserve = true
1514                                 unless need_preserve
1515                                         need_preserve = multi_sp_regex.test n.text
1516                                 if need_preserve
1517                                         # do we have it already?
1518                                         ws = @computed_style n, 'white-space' # FIXME implement this
1519                                         unless ws_props[ws]?.space
1520                                                 # 2nd arg is ideal target for css rule
1521                                                 ws = @preserve_space n, block
1522                                         eats_start_sp = false
1523                                 else
1524                                         if is_space_code n.text.charCodeAt(n.text.length - 1)
1525                                                 ws = @computed_style n, 'white-space' # FIXME implement this
1526                                                 if ws_props[ws]?.space
1527                                                         eats_start_sp = false
1528                                                 else
1529                                                         eats_start_sp = true
1530                                         else
1531                                                 eats_start_sp = false
1532                 # check if text ends with a collapsable space
1533                 if run.length > 0
1534                         last = run[run.length - 1]
1535                         if last.type is 'text'
1536                                 if eats_start_sp
1537                                         @preserve_space last, block
1538                 return
1539         css_clear: (n, prop) ->
1540                 return unless n.attrs.style?
1541                 return if n.attrs.style is ''
1542                 css_delimiter_regex = new RegExp('\s*;\s*', 'g') # FIXME make this global
1543                 styles = n.attrs.style.trim().split css_delimiter
1544                 return unless styles.length > 0
1545                 if styles[styles.length - 1] is ''
1546                         styles.pop()
1547                         return unless styles.length > 0
1548                 i = 0
1549                 while i < styles.length
1550                         if styles[i].substr(0, 12) is 'white-space:'
1551                                 styles.splice i, 1
1552                         else
1553                                 i += 1
1554                 return
1555         # WARNING: after calling this one or more times, you MUST:
1556         #    if it's inline: call @text_cleanup
1557         #    call @changed()
1558         remove_node: (n) ->
1559                 i = n.parent.children.indexOf n
1560                 if i is -1
1561                         throw "BUG #9187112313"
1562                 n.el.parentNode.removeChild n.el
1563                 n.parent.children.splice i, 1
1564                 return
1565         # remove a node from the tree/dom, insert into new_parent before insert_before?end
1566         # WARNING: after calling this one or more times, you MUST:
1567         #    if it's inline: call @text_cleanup
1568         #    call @changed()
1569         move_node: (n, new_parent, insert_before = null) ->
1570                 i = n.parent.children.indexOf n
1571                 if i is -1
1572                         throw "Error: tried to remove node, but it's not in it's parents list of children"
1573                         return
1574                 if insert_before?
1575                         before_i = new_parent.children.indexOf insert_before
1576                         if i is -1
1577                                 throw "Error: tried to move a node to be before a non-existent node"
1578                         insert_before = insert_before.el
1579                 @remove_node n
1580                 if insert_before?
1581                         new_parent.el.insertBefore n.el, insert_before
1582                         new_parent.children.splice before_i, 0, n
1583                 else
1584                         new_parent.el.appendChild n.el, insert_before
1585                         new_parent.children.push n
1586                 n.parent = new_parent
1587                 return
1588         kill_cursor: -> # remove it, forget where it was
1589                 if @cursor_visible
1590                         @cursor_el.parentNode.removeChild @cursor_el
1591                         @cursor_visible = false
1592                 @cursor = null
1593                 @annotate null
1594                 return
1595         move_cursor: (cursor) ->
1596                 @cursor_ideal_x = cursor.x
1597                 @cursor = cursor
1598                 unless @cursor_visible
1599                         @cursor_el = domify @outer_idoc, div: id: 'cursor'
1600                         @overlay.appendChild @cursor_el
1601                         @cursor_visible = true
1602                 @cursor_el.style.left = "#{cursor.x + overlay_padding - 1}px"
1603                 if cursor.h < 5
1604                         height = 12
1605                 else
1606                         height = cursor.h
1607                 @cursor_el.style.top = "#{cursor.y + overlay_padding + Math.round(height * .07)}px"
1608                 @cursor_el.style.height = "#{Math.round height * 0.82}px"
1609                 @annotate cursor.n
1610                 @scroll_into_view cursor.y, height
1611                 return
1612         scroll_into_view: (y, h = 0) ->
1613                 y += overlay_padding # convert units from @idoc to @wrap2
1614                 # very top of document
1615                 if y <= breathing_room
1616                         @wrap2.scrollTop = 0
1617                         return
1618                 # very bottom of document
1619                 if y + h >= @wrap2.scrollHeight - breathing_room
1620                         @wrap2.scrollTop = @wrap2.scrollHeight - @wrap2_height
1621                         return
1622                 # The most scrolled up (lowest value for scrollTop) that would be OK
1623                 upmost = y + h + breathing_room - @wrap2_height
1624                 upmost = Math.max(upmost, 0)
1625                 # the most scrolled down (highest value for scrollTop) that would be OK
1626                 downmost = y - breathing_room
1627                 downmost = Math.min(downmost, @wrap2.scrollHeight - @wrap2_height)
1628                 if upmost > downmost # means h is too big to fit
1629                         # scroll so top is visible
1630                         @wrap2.scrollTop = downmost
1631                         return
1632                 if @wrap2.scrollTop < upmost
1633                         @wrap2.scrollTop = upmost
1634                         return
1635                 if @wrap2.scrollTop > downmost
1636                         @wrap2.scrollTop = downmost
1637                         return
1638                 return
1639         annotate: (n) ->
1640                 while @matting.length > 0
1641                         @overlay.removeChild @matting[0]
1642                         @matting.shift()
1643                 return unless n?
1644                 prev_bounds = x: 0, y: 0, w: 0, h: 0
1645                 alpha = 0.1
1646                 while n?.el? and n isnt @tree_parent
1647                         if n.type is 'text'
1648                                 n = n.parent
1649                                 continue
1650                         bounds = get_el_bounds n.el
1651                         return unless bounds?
1652                         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
1653                                 n = n.parent
1654                                 continue
1655                         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});
1656                         @overlay.appendChild ann_box
1657                         @matting.push ann_box
1658                         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} "]
1659                         @overlay.appendChild ann_tag
1660                         @matting.push ann_tag
1661                         n = n.parent
1662                         alpha *= 1.5
1663         pretty_html: (tree, indent = '', parent_flags = pre_ish: false, block: true, want_nl: false) ->
1664                 ret = ''
1665                 want_nl = parent_flags.want_nl
1666                 prev_in_flow_is_text = false
1667                 prev_in_flow_is_block = false
1668                 for n, i in tree
1669                         # figure out flags
1670                         inner_flags = want_nl: true
1671                         is_br = false
1672                         switch n.type
1673                                 when 'tag'
1674                                         if n.name is 'br'
1675                                                 is_br = true
1676                                         is_text = false
1677                                         if n.el.currentStyle?
1678                                                 cs = n.el.currentStyle
1679                                                 whitespace = cs['white-space']
1680                                                 display = cs['display']
1681                                                 position = cs['position']
1682                                                 float = cs['float']
1683                                                 visibility = cs['visibility']
1684                                         else
1685                                                 cs = @iframe.contentWindow.getComputedStyle(n.el, null)
1686                                                 whitespace = cs.getPropertyValue 'white-space'
1687                                                 display = cs.getPropertyValue 'display'
1688                                                 position = cs.getPropertyValue 'position'
1689                                                 float = cs.getPropertyValue 'float'
1690                                                 visibility = cs.getPropertyValue 'visibility'
1691                                         if n.name is 'textarea'
1692                                                 inner_flags.pre_ish = true
1693                                         else
1694                                                 inner_flags.pre_ish = whitespace.substr(0, 3) is 'pre'
1695                                         switch float
1696                                                 when 'left', 'right'
1697                                                         in_flow = false
1698                                                 else
1699                                                         switch position
1700                                                                 when 'absolute', 'fixed'
1701                                                                         in_flow = false
1702                                                                 else
1703                                                                         if 'display' is 'none'
1704                                                                                 in_flow = false
1705                                                                         else
1706                                                                                 switch visibility
1707                                                                                         when 'hidden', 'collapse'
1708                                                                                                 in_flow = false
1709                                                                                         else # visible
1710                                                                                                 in_flow = true
1711                                         switch display
1712                                                 when 'inline', 'none'
1713                                                         inner_flags.block = false
1714                                                         is_block = in_flow_block = false
1715                                                 when 'inline-black'
1716                                                         inner_flags.block = true
1717                                                         is_block = in_flow_block = false
1718                                                 else # block, table, etc
1719                                                         inner_flags.block = true
1720                                                         is_block = true
1721                                                         in_flow_block = in_flow
1722                                 when 'text'
1723                                         is_text = true
1724                                         is_block = false
1725                                         in_flow = true
1726                                         in_flow_block = false
1727                                 else # 'comment', 'doctype'
1728                                         is_text = false
1729                                         is_block = false
1730                                         in_flow = false
1731                                         in_flow_block = false
1732                         # print whitespace if we can
1733                         unless parent_flags.pre_ish
1734                                 unless prev_in_flow_is_text and is_br
1735                                         if (i is 0 and parent_flags.block) or in_flow_block or prev_in_flow_is_block
1736                                                 if want_nl
1737                                                         ret += "\n"
1738                                                 ret += indent
1739                         switch n.type
1740                                 when 'tag'
1741                                         ret += '<' + n.name
1742                                         attr_keys = []
1743                                         for k of n.attrs
1744                                                 attr_keys.unshift k
1745                                         #attr_keys.sort()
1746                                         for k in attr_keys
1747                                                 ret += " #{k}"
1748                                                 if n.attrs[k].length > 0
1749                                                         ret += "=\"#{enc_attr n.attrs[k]}\""
1750                                         ret += '>'
1751                                         unless void_elements[n.name]?
1752                                                 if inner_flags.block
1753                                                         next_indent = indent + '    '
1754                                                 else
1755                                                         next_indent = indent
1756                                                 if n.children.length
1757                                                         ret += @pretty_html n.children, next_indent, inner_flags
1758                                                 ret += "</#{n.name}>"
1759                                 when 'text'
1760                                         ret += enc_text n.text
1761                                 when 'comment'
1762                                         ret += "<!--#{n.text}-->" # TODO encode?
1763                                 when 'doctype'
1764                                         ret += "<!DOCTYPE #{n.name}"
1765                                         if n.public_identifier? and n.public_identifier.length > 0
1766                                                 ret += " \"#{n.public_identifier}\""
1767                                         if n.system_identifier? and n.system_identifier.length > 0
1768                                                 ret += " \"#{n.system_identifier}\""
1769                                         ret += ">"
1770                         want_nl = true
1771                         if in_flow
1772                                 prev_in_flow_is_text = is_text
1773                                 prev_in_flow_is_block = is_block or (in_flow and is_br)
1774                 if tree.length
1775                         # output final newline if allowed
1776                         unless parent_flags.pre_ish
1777                                 if prev_in_flow_is_block or parent_flags.block
1778                                         ret += "\n#{indent.substr 4}"
1779                 return ret
1780         onblur: ->
1781                 @kill_cursor()
1782         have_focus: ->
1783                 @editor_is_focused = true
1784                 @poll_for_blur()
1785         poll_for_blur: ->
1786                 return if @poll_for_blur_timeout? # already polling
1787                 @poll_for_blur_timeout = timeout 150, =>
1788                         next_frame => # pause polling when browser knows we're not active/visible/etc.
1789                                 @poll_for_blur_timeout = null
1790                                 if document.activeElement is @outer_iframe
1791                                         @poll_for_blur()
1792                                 else
1793                                         @editor_is_focused = false
1794                                         @onblur()
1795
1796 window.peach_html5_editor = (args...) ->
1797         return new PeachHTML5Editor args...
1798
1799 # test in browser: peach_html5_editor(document.getElementsByTagName('textarea')[0])