JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
implement down arrow to move cursor
[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
20 timeout = (ms, cb) -> return setTimeout cb, ms
21 next_frame = (cb) ->
22         if (window.requestAnimationFrame?)
23                 window.requestAnimationFrame cb
24         else
25                 timeout 16, cb
26
27 this_url_sans_path = ->
28         ret = "#{window.location.href}"
29         clip = ret.lastIndexOf '#'
30         if clip > -1
31                 ret = ret.substr 0, clip
32         clip = ret.lastIndexOf '?'
33         if clip > -1
34                 ret = ret.substr 0, clip
35         clip = ret.lastIndexOf '/'
36         if clip > -1
37                 ret = ret.substr 0, clip + 1
38         return ret
39
40 # xml 1.0 spec, chromium and firefox accept these, plus lots of unicode chars
41 valid_attr_regex = new RegExp '^[a-zA-Z_:][-a-zA-Z0-9_:.]*$'
42 # html5 spec is much more lax, but chromium won't let me make at attribute with the name "4"
43 js_attr_regex = new RegExp '^[oO][nN].'
44
45 debug_dot_at = (doc, x, y) ->
46         return # disabled
47         el = doc.createElement 'div'
48         el.setAttribute 'style', "position: absolute; left: #{x}px; top: #{y}px; width: 1px; height: 3px; background-color: red"
49         doc.body.appendChild el
50         #console.log(new Error().stack)
51
52 # text nodes don't have getBoundingClientRect(), so use selection api to find
53 # it.
54 get_el_bounds = window.bounds = (el) ->
55         if el.getBoundingClientRect?
56                 rect = el.getBoundingClientRect()
57         else
58                 # text nodes don't have getBoundingClientRect(), so use range api
59                 range = el.ownerDocument.createRange()
60                 range.selectNodeContents el
61                 rect = range.getBoundingClientRect()
62         doc = el.ownerDocument.documentElement
63         win = el.ownerDocument.defaultView
64         y_fix = win.pageYOffset - doc.clientTop
65         x_fix = win.pageXOffset - doc.clientLeft
66         return {
67                 x: rect.left + x_fix
68                 y: rect.top + y_fix
69                 w: rect.width ? (rect.right - rect.left)
70                 h: rect.height ? (rect.top - rect.bottom)
71         }
72
73 is_display_block = (el) ->
74         if el.currentStyle?
75                 return el.currentStyle.display is 'block'
76         else
77                 return window.getComputedStyle(el, null).getPropertyValue('display') is 'block'
78
79 # Pass return value from dom event handlers to this.
80 # If they return false, this will addinionally stop propagation and default.
81 event_return = (e, bool) ->
82         if bool is false
83                 if e.stopPropagation?
84                         e.stopPropagation()
85                 if e.preventDefault?
86                         e.preventDefault()
87         return bool
88 # Warning: currently assumes you're asking about a single character
89 # Note: chromium returns multiple bounding rects for a space at a line-break
90 # Note: chromium's getBoundingClientRect() is broken (when zero-area client rects)
91 # Note: sometimes returns null (eg for whitespace that is not visible)
92 text_range_bounds = (el, start, end) ->
93         range = document.createRange()
94         range.setStart el, start
95         range.setEnd el, end
96         rects = range.getClientRects()
97         if rects.length > 0
98                 rect = rects[0]
99         else
100                 return null
101         doc = el.ownerDocument.documentElement
102         win = el.ownerDocument.defaultView
103         y_fix = win.pageYOffset - doc.clientTop
104         x_fix = win.pageXOffset - doc.clientLeft
105         return {
106                 x: rect.left + x_fix
107                 y: rect.top + y_fix
108                 w: rect.width ? (rect.right - rect.left)
109                 h: rect.height ? (rect.top - rect.bottom)
110                 rects: rects
111                 bounding: range.getBoundingClientRect()
112         }
113
114 class CursorPosition
115         constructor: (args) ->
116                 @n = args.n ? null
117                 @i = args.i ? null
118                 if args.x?
119                         @x = args.x
120                         @y = args.y
121                         @h = args.h
122                 else
123                         @set_xyh()
124                 return
125         set_xyh: ->
126                 range = document.createRange()
127                 if @n.text.length is 0
128                         ret = text_range_bounds @n.el, 0, 0
129                 else if @i is @n.text.length
130                         ret = text_range_bounds @n.el, @i - 1, @i
131                         if ret?
132                                 ret.x += ret.w
133                 else
134                         ret = text_range_bounds @n.el, @i, @i + 1
135                 if ret?
136                         @x = ret.x
137                         @y = ret.y
138                         @h = ret.h
139                 else
140                         @x = null
141                         @y = null
142                         @h = null
143                 return ret
144
145 new_cursor_position = (args) ->
146         ret = new CursorPosition args
147         if ret.x?
148                 return ret
149         return null
150
151 # encode text so it can be safely placed inside an html attribute
152 enc_attr_regex = new RegExp '(&)|(")|(\u00A0)', 'g'
153 enc_attr = (txt) ->
154         return txt.replace enc_attr_regex, (match, amp, quote) ->
155                 return '&amp;' if (amp)
156                 return '&quot;' if (quote)
157                 return '&nbsp;'
158 enc_text_regex = new RegExp '(&)|(<)|(\u00A0)', 'g'
159 enc_text = (txt) ->
160         return txt.replace enc_text_regex, (match, amp, lt) ->
161                 return '&amp;' if (amp)
162                 return '&lt;' if (lt)
163                 return '&nbsp;'
164
165 void_elements = {
166         area: true
167         base: true
168         br: true
169         col: true
170         embed: true
171         hr: true
172         img: true
173         input: true
174         keygen: true
175         link: true
176         meta: true
177         param: true
178         source: true
179         track: true
180         wbr: true
181 }
182 # TODO make these always pretty-print (on the inside) like blocks
183 no_text_elements = { # these elements never contain text
184         select: true
185         table: true
186         tr: true
187         thead: true
188         tbody: true
189         ul: true
190         ol: true
191 }
192
193 domify = (doc, hash) ->
194         for tag, attrs of hash
195                 if tag is 'text'
196                         return document.createTextNode attrs
197                 el = document.createElement tag
198                 for k, v of attrs
199                         if k is 'children'
200                                 for child in v
201                                         el.appendChild child
202                         else
203                                 el.setAttribute k, v
204         return el
205
206 outer_css = (args) ->
207         w = args.w ? 300
208         h = args.h ? 300
209         inner_padding = args.inner_padding ? overlay_padding
210         frame_width = args.frame_width ? inner_padding
211         # TODO editor controls height...
212         occupy = (left, top = left, right = left, bottom = top) ->
213                 w -= left + right
214                 h -= top + bottom
215                 return Math.max(left, top, right, bottom)
216         ret = ''
217         ret += 'body {'
218         ret +=     'margin: 0;'
219         ret +=     'padding: 0;'
220         ret +=     'color: black;'
221         ret +=     'background: white;'
222         ret += '}'
223         ret += '#wrap1 {'
224         ret +=     "border: #{occupy 1}px solid black;"
225         ret +=     "padding: #{occupy frame_width}px;"
226         ret += '}'
227         ret += '#wrap2 {'
228         ret +=     "border: #{occupy 1}px solid black;"
229         ret +=     "padding: #{occupy inner_padding}px;"
230         ret +=     "padding-right: #{inner_padding + occupy 0, 0, 15, 0}px;" # for scroll bar
231         ret +=     "width: #{w}px;"
232         ret +=     "height: #{h}px;"
233         ret +=     'overflow-x: hidden;'
234         ret +=     'overflow-y: scroll;'
235         ret += '}'
236         ret += '#wrap3 {'
237         ret +=     'position: relative;'
238         ret +=     "width: #{w}px;"
239         ret +=     "min-height: #{h}px;"
240         ret += '}'
241         ret += 'iframe {'
242         ret +=     'box-sizing: border-box;'
243         ret +=     'margin: 0;'
244         ret +=     'border: none;'
245         ret +=     'padding: 0;'
246         ret +=     "width: #{w}px;"
247         #ret +=     "height: #{h}px;" # height auto-set when content set/changed
248         ret +=     '-ms-user-select: none;'
249         ret +=     '-webkit-user-select: none;'
250         ret +=     '-moz-user-select: none;'
251         ret +=     'user-select: none;'
252         ret += '}'
253         ret += '#overlay {'
254         ret +=     'position: absolute;'
255         ret +=     "left: -#{inner_padding}px;"
256         ret +=     "top: -#{inner_padding}px;"
257         ret +=     "right: -#{inner_padding}px;"
258         ret +=     "bottom: -#{inner_padding}px;"
259         ret +=     'overflow: hidden;'
260         ret += '}'
261         ret += '.lightbox {'
262         ret +=     'position: absolute;'
263         ret +=     'background: rgba(100,100,100,0.2);'
264         ret += '}'
265         ret += '#cursor {'
266         ret +=     'position: absolute;'
267         ret +=     'width: 2px;'
268         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));'
269         ret +=     'background-size: 200% 200%;'
270         ret +=     '-webkit-animation: blink 1s linear normal infinite;'
271         ret +=     'animation: blink 1s linear normal infinite;'
272         ret += '}'
273         ret += '@-webkit-keyframes blink {'
274         ret +=     '0%{background-position:0% 0%}'
275         ret +=     '100%{background-position:0% -100%}'
276         ret += '}'
277         ret += '@keyframes blink { '
278         ret +=     '0%{background-position:0% 0%}'
279         ret +=     '100%{background-position:0% -100%}'
280         ret += '}'
281         ret += '.ann_box {'
282         ret +=     'z-index: 5;'
283         ret +=     'position: absolute;'
284         ret +=     'border: 1px solid rgba(0,0,0,0.1);'
285         ret +=     'outline: 1px solid rgba(255,255,255,0.1);' # in case there's a black background
286         ret += '}'
287         ret += '.ann_tag {'
288         ret +=     'z-index: 10;'
289         ret +=     'position: absolute;'
290         ret +=     'font-size: 8px;'
291         ret +=     'white-space: pre;'
292         ret +=     'background: rgba(255,255,255,0.4);'
293         ret +=     '-ms-user-select: none;'
294         ret +=     '-webkit-user-select: none;'
295         ret +=     '-moz-user-select: none;'
296         ret +=     'user-select: none;'
297         ret += '}'
298         return ret
299
300
301 ignore_key_codes =
302         '18': true # alt
303         '20': true # capslock
304         '17': true # ctrl
305         '144': true # numlock
306         '16': true # shift
307         '91': true # windows "start" key
308 # key codes: (valid on keydown, not keypress)
309 KEY_LEFT = 37
310 KEY_UP = 38
311 KEY_RIGHT = 39
312 KEY_DOWN = 40
313 KEY_BACKSPACE = 8 # <--
314 KEY_DELETE = 46 # -->
315 KEY_END = 35
316 KEY_ENTER = 13
317 KEY_ESCAPE = 27
318 KEY_HOME = 36
319 KEY_INSERT = 45
320 KEY_PAGE_UP = 33
321 KEY_PAGE_DOWN = 34
322 KEY_TAB = 9
323 control_key_codes = # we react to these, but they aren't typing
324         '37': KEY_LEFT
325         '38': KEY_UP
326         '39': KEY_RIGHT
327         '40': KEY_DOWN
328         '35': KEY_END
329         '8':  KEY_BACKSPACE
330         '46': KEY_DELETE
331         '13': KEY_ENTER
332         '27': KEY_ESCAPE
333         '36': KEY_HOME
334         '45': KEY_INSERT
335         '33': KEY_PAGE_UP
336         '34': KEY_PAGE_DOWN
337         '9':  KEY_TAB
338
339 instantiate_tree = (tree, parent) ->
340         remove = []
341         for c, i in tree
342                 switch c.type
343                         when 'text'
344                                 c.el = parent.ownerDocument.createTextNode c.text
345                                 parent.appendChild c.el
346                         when 'tag'
347                                 if c.name in ['script', 'object', 'iframe', 'link']
348                                         # TODO put placeholders instead
349                                         remove.unshift i
350                                         continue
351                                 # TODO create in correct namespace
352                                 c.el = parent.ownerDocument.createElement c.name
353                                 for k, v of c.attrs
354                                         # FIXME if attr_whitelist[k]?
355                                         if valid_attr_regex.test k
356                                                 unless js_attr_regex.test k
357                                                         c.el.setAttribute k, v
358                                 parent.appendChild c.el
359                                 if c.children.length
360                                         instantiate_tree c.children, c.el
361         for i in remove
362                 tree.splice i, 1
363
364 traverse_tree = (tree, cb) ->
365         done = false
366         for c in tree
367                 done = cb c
368                 return done if done
369                 if c.children.length
370                         done = traverse_tree c.children, cb
371                         return done if done
372         return done
373
374 first_cursor_position = (tree) ->
375         found = null
376         traverse_tree tree, (node, state) ->
377                 if node.type is 'text'
378                         cursor = new_cursor_position n: node, i: 0
379                         if cursor?
380                                 found = cursor
381                                 return true
382                 return false
383         return found # maybe null
384
385 # this will fail when text has non-locatable cursor positions
386 find_next_cursor_position = (tree, cursor) ->
387         if cursor.n.type is 'text' and cursor.n.text.length > cursor.i
388                 new_cursor = new_cursor_position n: cursor.n, i: cursor.i + 1
389                 if new_cursor?
390                         return new_cursor
391         state_before = true
392         found = null
393         traverse_tree tree, (node, state) ->
394                 if node.type is 'text' and state_before is false
395                         new_cursor = new_cursor_position n: node, i: 0
396                         if new_cursor?
397                                 found = new_cursor
398                                 return true
399                 if node is cursor.n
400                         state_before = false
401                 return false
402         if found?
403                 return found
404         return null
405
406 last_cursor_position = (tree) ->
407         found = null
408         traverse_tree tree, (node) ->
409                 if node.type is 'text'
410                         cursor = new_cursor_position n: node, i: node.text.length
411                         if cursor?
412                                 found = cursor
413                 return false
414         return found # maybe null
415
416 # this will fail when text has non-locatable cursor positions
417 find_prev_cursor_position = (tree, cursor) ->
418         if cursor.n.type is 'text' and cursor.i > 0
419                 new_cursor = new_cursor_position n: cursor.n, i: cursor.i - 1
420                 if new_cursor?
421                         return new_cursor
422         found_prev = null
423         found = null
424         traverse_tree tree, (node) ->
425                 if node is cursor.n
426                         found = found_prev # maybe null
427                         return true
428                 if node.type is 'text'
429                         new_cursor = new_cursor_position n: node, i: node.text.length
430                         if new_cursor?
431                                 found_prev = new_cursor
432                 return false
433         return found # maybe null
434
435 xy_to_cursor = (tree, xy) ->
436         for n in tree
437                 if n.type is 'tag' or n.type is 'text'
438                         bounds = get_el_bounds n.el
439                         continue if xy.x < bounds.x
440                         continue if xy.x > bounds.x + bounds.w
441                         continue if xy.y < bounds.y
442                         continue if xy.y > bounds.y + bounds.h
443                         if n.children.length
444                                 ret = xy_to_cursor n.children, xy
445                                 return ret if ret?
446                         if n.type is 'text'
447                                 # click is within bounding box that contains all text.
448                                 if n.text.length is 0
449                                         ret = new_cursor_position n: n, i: 0
450                                         return ret if ret?
451                                         continue
452                                 before = new_cursor_position n: n, i: 0
453                                 continue unless before?
454                                 after = new_cursor_position n: n, i: n.text.length
455                                 continue unless after?
456                                 if xy.y < before.y + before.h and xy.x < before.x
457                                         # console.log 'before first char on first line'
458                                         continue
459                                 if xy.y > after.y and xy.x > after.x
460                                         # console.log 'after last char on last line'
461                                         continue
462                                 if xy.y < before.y
463                                         console.log "Warning: click in text bounding box but above first line"
464                                         continue # above first line (runaround?)
465                                 if xy.y > after.y + after.h
466                                         console.log "Warning: click in text bounding box but below last line", xy.y, after.y, after.h
467                                         continue # below last line (shouldn't happen?)
468                                 while after.i - before.i > 1
469                                         guess_i = Math.round((before.i + after.i) / 2)
470                                         cur = new_cursor_position n: n, i: guess_i
471                                         unless cur?
472                                                 console.log "error: failed to find cursor pixel location for", n, guess_i
473                                                 before = null
474                                                 break
475                                         if xy.y < cur.y or (xy.y <= cur.y + cur.h and xy.x < cur.x)
476                                                 after = cur
477                                         else
478                                                 before = cur
479                                 continue unless before? # signals failure to find a cursor position
480                                 # which one is closest?
481                                 if Math.abs(before.x - xy.x) < Math.abs(after.x - xy.x)
482                                         return before
483                                 else
484                                         return after
485         return null
486
487 # browsers collapse these (html5 spec calls these "space characters")
488 is_space_code = (char_code) ->
489         switch char_code
490                 when 9, 10, 12, 13, 32
491                         return true
492         return false
493 is_space = (chr) ->
494         return is_space_code chr.charCodeAt 0
495
496 tree_remove_empty_text_nodes = (tree) ->
497         empties = []
498         traverse_tree tree, (n) ->
499                 if n.type is 'text'
500                         if n.text.length is 0
501                                 empties.unshift n
502                 return false
503         for n in empties
504                 # don't completely empty the tree
505                 if tree.length is 1
506                         if tree[0].type is 'text'
507                                 console.log "oop, leaving a blank node because it's the only thing"
508                                 return
509                 n.el.parentNode.removeChild n.el
510                 for c, i in n.parent.children
511                         if c is n
512                                 n.parent.children.splice i, 1
513                                 break
514
515 # pass a array of nodes (from parser library, ie it should have .el and .text)
516 tree_dedup_space = (tree) ->
517         prev = cur = next = null
518         prev_i = cur_i = next_i = 0
519         prev_pos = pos = next_pos = null
520         prev_px = cur_px = next_px = null
521         first = true
522         removed_char = null
523
524         tree_remove_empty_text_nodes(tree)
525
526         iterate = (tree, cb) ->
527                 for n in tree
528                         if n.type is 'text'
529                                 i = 0
530                                 while i < n.text.length # don't foreach, cb might remove chars
531                                         advance = cb n, i
532                                         if advance
533                                                 i += 1
534                         if n.type is 'tag'
535                                 block = is_display_block n.el
536                                 if block
537                                         cb null
538                                 if n.children.length > 0
539                                         iterate n.children, cb
540                                 if block
541                                         cb null
542         # remove cur char
543         remove = ->
544                 removed_char = cur.text.charAt(cur_i)
545                 cur.el.textContent = cur.text = (cur.text.substr 0, cur_i) + (cur.text.substr cur_i + 1)
546                 if next is cur # in same text node
547                         if next_i is 0
548                                 throw "how is this possible?"
549                         next_i -= 1
550                 return true
551         # undo remove()
552         put_it_back = ->
553                 cur.el.textContent = cur.text = (cur.text.substr 0, cur_i) + removed_char + (cur.text.substr cur_i)
554                 if next is cur # in same text node
555                         next_i += 1
556                 return false
557         # return true if cur was removed from the dom (ie re-use same prev)
558         operate = ->
559                 # cur definitately set
560                 # prev and/or next might be null, indicating the start/end of a display:block
561                 return false unless is_space_code cur.text.charCodeAt cur_i
562                 bounds = text_range_bounds cur.el, cur_i, cur_i + 1
563                 # consistent cases:
564                 # 1. zero rects returned by getClientRects() means collapsed space
565                 if bounds is null
566                         return remove()
567                 # 2. width greater than zero means visible space
568                 if bounds.w > 0
569                         return false
570                 # now the weird edge cases...
571                 #
572                 # firefox and chromium both report zero width for characters at the end
573                 # of a line where the text wraps (automatically, due to word-wrap) to
574                 # the next line. These do not appear to be distinguishable from
575                 # collapsed spaces via the range/bounds api, so...
576                 #
577                 # remove it from the dom, and if prev or next moves, put it back.
578                 if prev? and not prev_px?
579                         prev_px = new_cursor_position n: prev, i: prev_i
580                 if next? and not next_px?
581                         next_px = new_cursor_position n: next, i: next_i
582                 #if prev is null and next is null
583                 #       parent_px = cur.parent.el.getBoundingClientRect()
584                 remove()
585                 if prev?
586                         if prev_px?
587                                 new_prev_px = new_cursor_position n: prev, i: prev_i
588                                 if new_prev_px.x isnt prev_px.x or new_prev_px.y isnt prev_px.y
589                                         return put_it_back()
590                         else
591                                 console.log "this shouldn't happen, we remove spaces that don't locate"
592                 if next?
593                         if next_px?
594                                 new_next_px = new_cursor_position n: next, i: next_i
595                                 if new_next_px.x isnt next_px.x or new_next_px.y isnt next_px.y
596                                         return put_it_back()
597                         #else
598                         #       console.log "removing space becase space after it is collapsed"
599                 return true
600         # pass null at start/end of display:block
601         queue = (n, i) ->
602                 next = n
603                 next_i = i
604                 next_px = null
605                 advance = true
606                 if cur?
607                         removed = operate()
608                         # don't advance (to the next character next time) if we removed a
609                         # character from the same text node as ``next``, because doing so
610                         # renumbers the indexes in that string
611                         if removed and cur is next
612                                 advance = false
613                 else
614                         removed = false
615                 unless removed
616                         prev = cur
617                         prev_i = cur_i
618                         prev_px = cur_px
619                 cur = next
620                 cur_i = next_i
621                 cur_px = next_px
622                 return advance
623         queue null
624         iterate tree, queue
625         queue null
626
627         tree_remove_empty_text_nodes(tree)
628
629 class PeachHTML5Editor
630         # Options: (all optional)
631         #   editor_id: "id" attribute for outer-most element created by/for editor
632         #   css_file: filename of a css file to style editable content
633         #   on_init: callback for when the editable content is in place
634         constructor: (in_el, options) ->
635                 @options = options ? {}
636                 @in_el = in_el
637                 @tree = null
638                 @matting = []
639                 @init_1_called = false # when iframes have loaded
640                 @outer_iframe # iframe to hold editor
641                 @outer_idoc # "document" object for @outer_iframe
642                 @wrap2 = null # scrollbar is on this
643                 @iframe = null # iframe to hold editable content
644                 @idoc = null # "document" object for @iframe
645                 @cursor = null
646                 @cursor_el = null
647                 @cursor_visible = false
648                 @poll_for_blur_timeout = null
649                 @wrap2_offset = null
650                 @iframe_height = null
651                 opt_fragment = @options.fragment ? true
652                 @parser_opts = {}
653                 if opt_fragment
654                         @parser_opts.fragment = 'body'
655
656                 @outer_iframe = domify document, iframe: {}
657                 outer_iframe_style = 'border: none !important; margin: 0 !important; padding: 0 !important; height: 100% !important; width: 100% !important;'
658                 if @options.editor_id?
659                         @outer_iframe.setAttribute 'id', @options.editor_id
660                 @outer_iframe.onload = =>
661                         @outer_idoc = @outer_iframe.contentDocument
662                         icss = domify @outer_idoc, style: children: [
663                                 domify @outer_idoc, text: css
664                         ]
665                         @outer_idoc.head.appendChild icss
666                         @iframe = domify @outer_idoc, iframe: sandbox: 'allow-same-origin allow-scripts'
667                         @iframe.onload = =>
668                                 @init_1()
669                         timeout 200, => # firefox never fires this onload
670                                 @init_1() unless @init_1_called
671                         @outer_idoc.body.appendChild(
672                                 domify @outer_idoc, div: id: 'wrap1', children: [
673                                         domify @outer_idoc, div: style: "position: absolute; top: 0; left: 1px; font-size: 10px", children: [ domify @outer_idoc, text: "Peach HTML5 Editor" ]
674                                         @wrap2 = domify @outer_idoc, div: id: 'wrap2', children: [
675                                                 domify @outer_idoc, div: id: 'wrap3', children: [
676                                                         @iframe
677                                                         @overlay = domify @outer_idoc, div: id: 'overlay'
678                                                 ]
679                                         ]
680                                 ]
681                         )
682                 outer_wrap = domify document, div: class: 'peach_html5_editor'
683                 @in_el.parentNode.appendChild outer_wrap
684                 outer_bounds = get_el_bounds outer_wrap
685                 if outer_bounds.w < 300
686                         outer_bounds.w = 300
687                 if outer_bounds.h < 300
688                         outer_bounds.h = 300
689                 outer_iframe_style += "width: #{outer_bounds.w}px; height: #{outer_bounds.h}px;"
690                 @outer_iframe.setAttribute 'style', outer_iframe_style
691                 css = outer_css w: outer_bounds.w, h: outer_bounds.h
692                 outer_wrap.appendChild @outer_iframe
693         init_1: -> # @iframe has loaded (but not it's css)
694                 @idoc = @iframe.contentDocument
695                 @init_1_called = true
696                 # chromium doesn't resolve relative urls as though they were at the same domain
697                 # so add a <base> tag
698                 @idoc.head.appendChild domify @idoc, base: href: this_url_sans_path()
699                 # don't let @iframe have scrollbars
700                 @idoc.head.appendChild domify @idoc, style: children: [domify @idoc, text: "body { overflow: hidden; }"]
701                 # load css file
702                 if @options.css_file
703                         istyle = domify @idoc, link: rel: 'stylesheet', href: @options.css_file
704                         istyle.onload = =>
705                                 @init_2()
706                         @idoc.head.appendChild istyle
707                 else
708                         @init_2()
709         init_2: -> # @iframe and it's css file(s) are ready
710                 @overlay.onclick = (e) =>
711                         @have_focus()
712                         return event_return e, @onclick e
713                 @overlay.ondoubleclick = (e) =>
714                         @have_focus()
715                         return event_return e, @ondoubleclick e
716                 @outer_idoc.body.onkeyup = (e) =>
717                         @have_focus()
718                         return event_return e, @onkeyup e
719                 @outer_idoc.body.onkeydown = (e) =>
720                         @have_focus()
721                         return event_return e, @onkeydown e
722                 @outer_idoc.body.onkeypress = (e) =>
723                         @have_focus()
724                         return event_return e, @onkeypress e
725                 @load_html @in_el.value
726                 if @options.on_init?
727                         @options.on_init()
728         overlay_event_to_inner_xy: (e) ->
729                 unless @wrap2_offset?
730                         @wrap2_offset = get_el_bounds @wrap2
731                 x = e.pageX - overlay_padding
732                 y = e.pageY - overlay_padding + @wrap2.scrollTop
733                 return x: x - @wrap2_offset.x, y: y - @wrap2_offset.y
734         onclick: (e) ->
735                 xy = @overlay_event_to_inner_xy e
736                 new_cursor = xy_to_cursor @tree, xy
737                 if new_cursor?
738                         @move_cursor new_cursor
739                 else
740                         @kill_cursor()
741                 return false
742         ondoubleclick: (e) ->
743                 return false
744         onkeyup: (e) ->
745                 return if e.ctrlKey
746                 return false if ignore_key_codes[e.keyCode]?
747                 #return false if control_key_codes[e.keyCode]?
748         onkeydown: (e) ->
749                 return if e.ctrlKey
750                 return false if ignore_key_codes[e.keyCode]?
751                 #return false if control_key_codes[e.keyCode]?
752                 switch e.keyCode
753                         when KEY_LEFT
754                                 if @cursor?
755                                         new_cursor = find_prev_cursor_position @tree, @cursor
756                                 else
757                                         new_cursor = first_cursor_position @tree
758                                 if new_cursor?
759                                         @move_cursor new_cursor
760                                 return false
761                         when KEY_RIGHT
762                                 if @cursor?
763                                         new_cursor = find_next_cursor_position @tree, @cursor
764                                 else
765                                         new_cursor = last_cursor_position @tree
766                                 if new_cursor?
767                                         @move_cursor new_cursor
768                                 return false
769                         when KEY_UP
770                                 if @cursor?
771                                         new_cursor = @cursor
772                                         # go prev until we're higher on y axis
773                                         while new_cursor.y >= @cursor.y
774                                                 new_cursor = find_prev_cursor_position @tree, new_cursor
775                                                 return false unless new_cursor?
776                                         # done early if we're already left of old cursor position
777                                         if new_cursor.x <= @cursor.x
778                                                 @move_cursor new_cursor
779                                                 return false
780                                         target_y = new_cursor.y
781                                         # search leftward, until we find the closest position
782                                         # new_cursor is the prev-most position we've checked
783                                         # prev_cursor is the older value, so it's not as prev as new_cursor
784                                         while new_cursor.x > @cursor.x and new_cursor.y is target_y
785                                                 prev_cursor = new_cursor
786                                                 new_cursor = find_prev_cursor_position @tree, new_cursor
787                                                 break unless new_cursor?
788                                         # move cursor to prev_cursor or new_cursor
789                                         if new_cursor?
790                                                 if new_cursor.y is target_y
791                                                         # both valid, and on the same line, use closest
792                                                         if (@cursor.x - new_cursor.x) < (prev_cursor.x - @cursor.x)
793                                                                 @move_cursor new_cursor
794                                                         else
795                                                                 @move_cursor prev_cursor
796                                                 else
797                                                         # new_cursor on wrong line, use prev_cursor
798                                                         @move_cursor prev_cursor
799                                         else
800                                                 # can't go any further prev, use prev_cursor
801                                                 @move_cursor prev_cursor
802                                 else
803                                         # move cursor to first position in document
804                                         new_cursor = first_cursor_position @tree
805                                         if new_cursor?
806                                                 @move_cursor new_cursor
807                                 return false
808                         when KEY_DOWN
809                                 if @cursor?
810                                         new_cursor = @cursor
811                                         # go next until we move on the y axis
812                                         while new_cursor.y <= @cursor.y
813                                                 new_cursor = find_next_cursor_position @tree, new_cursor
814                                                 return false unless new_cursor?
815                                         # done early if we're already right of old cursor position
816                                         if new_cursor.x >= @cursor.x
817                                                 # this would be strange, but could happen due to runaround
818                                                 @move_cursor new_cursor
819                                                 return false
820                                         target_y = new_cursor.y
821                                         # search rightward, until we find the closest position
822                                         # new_cursor is the next-most position we've checked
823                                         # prev_cursor is the older value, so it's not as next as new_cursor
824                                         while new_cursor.x < @cursor.x and new_cursor.y is target_y
825                                                 prev_cursor = new_cursor
826                                                 new_cursor = find_next_cursor_position @tree, new_cursor
827                                                 break unless new_cursor?
828                                         # move cursor to prev_cursor or new_cursor
829                                         if new_cursor?
830                                                 if new_cursor.y is target_y
831                                                         # both valid, and on the same line, use closest
832                                                         if (new_cursor.x - @cursor.x) < (@cursor.x - prev_cursor.x)
833                                                                 @move_cursor new_cursor
834                                                         else
835                                                                 @move_cursor prev_cursor
836                                                 else
837                                                         # new_cursor on wrong line, use prev_cursor
838                                                         @move_cursor prev_cursor
839                                         else
840                                                 # can't go any further prev, use prev_cursor
841                                                 @move_cursor prev_cursor
842                                 else
843                                         # move cursor to first position in document
844                                         new_cursor = last_cursor_position @tree
845                                         if new_cursor?
846                                                 @move_cursor new_cursor
847                                 return false
848                         when KEY_END
849                                 return false
850                         when KEY_BACKSPACE
851                                 return false unless @cursor?
852                                 return false unless @cursor[1] > 0
853                                 @cursor[0].text = @cursor[0].text.substr(0, @cursor[1] - 1) + @cursor[0].text.substr(@cursor[1])
854                                 @cursor[0].el.nodeValue = @cursor[0].text
855                                 @move_cursor [@cursor[0], @cursor[1] - 1]
856                                 @changed()
857                                 return false
858                         when KEY_DELETE
859                                 return false unless @cursor?
860                                 return false unless @cursor[1] < @cursor[0].text.length
861                                 @cursor[0].text = @cursor[0].text.substr(0, @cursor[1]) + @cursor[0].text.substr(@cursor[1] + 1)
862                                 @cursor[0].el.nodeValue = @cursor[0].text
863                                 @move_cursor [@cursor[0], @cursor[1]]
864                                 @changed()
865                                 return false
866                         when KEY_ENTER
867                                 return false
868                         when KEY_ESCAPE
869                                 return false
870                         when KEY_HOME
871                                 return false
872                         when KEY_INSERT
873                                 return false
874                         when KEY_PAGE_UP
875                                 return false
876                         when KEY_PAGE_DOWN
877                                 return false
878                         when KEY_TAB
879                                 return false
880         onkeypress: (e) ->
881                 return if e.ctrlKey
882                 return false if ignore_key_codes[e.keyCode]?
883                 # return false if control_key_codes[e.keyCode]? # handled in keydown
884                 char = e.charCode ? e.keyCode
885                 if char and @cursor?
886                         char = String.fromCharCode char
887                         if @cursor[1] is 0
888                                 @cursor[0].text = char + @cursor[0].text
889                         else if @cursor[1] is @cursor[0].text.length - 1
890                                 @cursor[0].text += char
891                         else
892                                 @cursor[0].text =
893                                         @cursor[0].text.substr(0, @cursor[1]) +
894                                         char +
895                                         @cursor[0].text.substr(@cursor[1])
896                         @cursor[0].el.nodeValue = @cursor[0].text
897                         @move_cursor [@cursor[0], @cursor[1] + 1]
898                         @changed()
899                 return false
900         clear_dom: -> # remove all the editable content (and cursor, overlays, etc)
901                 while @idoc.body.childNodes.length
902                         @idoc.body.removeChild @idoc.body.childNodes[0]
903                 @kill_cursor()
904                 return
905         load_html: (html) ->
906                 @tree = peach_parser.parse html, @parser_opts
907                 @clear_dom()
908                 instantiate_tree @tree, @idoc.body
909                 tree_dedup_space @tree
910                 @changed()
911         changed: ->
912                 @in_el.onchange = null
913                 @in_el.value = @pretty_html @tree
914                 @in_el.onchange = =>
915                         @load_html @in_el.value
916                 @adjust_iframe_height()
917         adjust_iframe_height: ->
918                 h = parseInt(@idoc.body.scrollHeight, 10)
919                 if @iframe_height isnt h
920                         @iframe_height = h
921                         s = @wrap2.scrollTop
922                         @iframe.style.height = "0"
923                         @iframe.style.height = "#{h}px"
924                         @wrap2.scrollTop = s
925         kill_cursor: -> # remove it, forget where it was
926                 if @cursor_visible
927                         @cursor_el.parentNode.removeChild @cursor_el
928                         @cursor_visible = false
929                 @cursor = null
930                 @annotate null
931         move_cursor: (cursor) ->
932                 @cursor = cursor
933                 unless @cursor_visible
934                         @cursor_el = domify @outer_idoc, div: id: 'cursor'
935                         @overlay.appendChild @cursor_el
936                         @cursor_visible = true
937                 @cursor_el.style.left = "#{cursor.x + overlay_padding - 1}px"
938                 if cursor.h < 5
939                         height = 12
940                 else
941                         height = cursor.h
942                 @cursor_el.style.top = "#{cursor.y + overlay_padding + Math.round(height * .07)}px"
943                 @cursor_el.style.height = "#{Math.round height * 0.82}px"
944                 @annotate cursor.n
945         annotate: (n) ->
946                 while @matting.length > 0
947                         @overlay.removeChild @matting[0]
948                         @matting.shift()
949                 return unless n?
950                 prev_bounds = x: 0, y: 0, w: 0, h: 0
951                 alpha = 0.1
952                 while n?.el?
953                         if n.type is 'text'
954                                 n = n.parent
955                                 continue
956                         bounds = get_el_bounds n.el
957                         return unless bounds?
958                         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
959                                 n = n.parent
960                                 continue
961                         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});
962                         @overlay.appendChild ann_box
963                         @matting.push ann_box
964                         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} "]
965                         @overlay.appendChild ann_tag
966                         @matting.push ann_tag
967                         n = n.parent
968                         alpha *= 1.5
969         pretty_html: (tree, indent = '', parent_flags = pre_ish: false, block: true, want_nl: false) ->
970                 ret = ''
971                 want_nl = parent_flags.want_nl
972                 prev_in_flow_is_text = false
973                 prev_in_flow_is_block = false
974                 for n, i in tree
975                         # figure out flags
976                         inner_flags = want_nl: true
977                         is_br = false
978                         switch n.type
979                                 when 'tag'
980                                         if n.name is 'br'
981                                                 is_br = true
982                                         is_text = false
983                                         if n.el.currentStyle?
984                                                 cs = n.el.currentStyle
985                                                 whitespace = cs['white-space']
986                                                 display = cs['display']
987                                                 position = cs['position']
988                                                 float = cs['float']
989                                                 visibility = cs['visibility']
990                                         else
991                                                 cs = @iframe.contentWindow.getComputedStyle(n.el, null)
992                                                 whitespace = cs.getPropertyValue 'white-space'
993                                                 display = cs.getPropertyValue 'display'
994                                                 position = cs.getPropertyValue 'position'
995                                                 float = cs.getPropertyValue 'float'
996                                                 visibility = cs.getPropertyValue 'visibility'
997                                         if n.name is 'textarea'
998                                                 inner_flags.pre_ish = true
999                                         else
1000                                                 inner_flags.pre_ish = whitespace.substr(0, 3) is 'pre'
1001                                         switch float
1002                                                 when 'left', 'right'
1003                                                         in_flow = false
1004                                                 else
1005                                                         switch position
1006                                                                 when 'absolute', 'fixed'
1007                                                                         in_flow = false
1008                                                                 else
1009                                                                         if 'display' is 'none'
1010                                                                                 in_flow = false
1011                                                                         else
1012                                                                                 switch visibility
1013                                                                                         when 'hidden', 'collapse'
1014                                                                                                 in_flow = false
1015                                                                                         else # visible
1016                                                                                                 in_flow = true
1017                                         switch display
1018                                                 when 'inline', 'none'
1019                                                         inner_flags.block = false
1020                                                         is_block = in_flow_block = false
1021                                                 when 'inline-black'
1022                                                         inner_flags.block = true
1023                                                         is_block = in_flow_block = false
1024                                                 else # block, table, etc
1025                                                         inner_flags.block = true
1026                                                         is_block = true
1027                                                         in_flow_block = in_flow
1028                                 when 'text'
1029                                         is_text = true
1030                                         is_block = false
1031                                         in_flow = true
1032                                         in_flow_block = false
1033                                 else # 'comment', 'doctype'
1034                                         is_text = false
1035                                         is_block = false
1036                                         in_flow = false
1037                                         in_flow_block = false
1038                         # print whitespace if we can
1039                         unless parent_flags.pre_ish
1040                                 unless prev_in_flow_is_text and is_br
1041                                         if (i is 0 and parent_flags.block) or in_flow_block or prev_in_flow_is_block
1042                                                 if want_nl
1043                                                         ret += "\n"
1044                                                 ret += indent
1045                         switch n.type
1046                                 when 'tag'
1047                                         ret += '<' + n.name
1048                                         attr_keys = []
1049                                         for k of n.attrs
1050                                                 attr_keys.unshift k
1051                                         #attr_keys.sort()
1052                                         for k in attr_keys
1053                                                 ret += " #{k}"
1054                                                 if n.attrs[k].length > 0
1055                                                         ret += "=\"#{enc_attr n.attrs[k]}\""
1056                                         ret += '>'
1057                                         unless void_elements[n.name]?
1058                                                 if inner_flags.block
1059                                                         next_indent = indent + '    '
1060                                                 else
1061                                                         next_indent = indent
1062                                                 if n.children.length
1063                                                         ret += @pretty_html n.children, next_indent, inner_flags
1064                                                 ret += "</#{n.name}>"
1065                                 when 'text'
1066                                         ret += enc_text n.text
1067                                 when 'comment'
1068                                         ret += "<!--#{n.text}-->" # TODO encode?
1069                                 when 'doctype'
1070                                         ret += "<!DOCTYPE #{n.name}"
1071                                         if n.public_identifier? and n.public_identifier.length > 0
1072                                                 ret += " \"#{n.public_identifier}\""
1073                                         if n.system_identifier? and n.system_identifier.length > 0
1074                                                 ret += " \"#{n.system_identifier}\""
1075                                         ret += ">"
1076                         want_nl = true
1077                         if in_flow
1078                                 prev_in_flow_is_text = is_text
1079                                 prev_in_flow_is_block = is_block or (in_flow and is_br)
1080                 if tree.length
1081                         # output final newline if allowed
1082                         unless parent_flags.pre_ish
1083                                 if prev_in_flow_is_block or parent_flags.block
1084                                         ret += "\n#{indent.substr 4}"
1085                 return ret
1086         onblur: ->
1087                 @kill_cursor()
1088         have_focus: ->
1089                 @editor_is_focused = true
1090                 @poll_for_blur()
1091         poll_for_blur: ->
1092                 return if @poll_for_blur_timeout? # already polling
1093                 @poll_for_blur_timeout = timeout 150, =>
1094                         next_frame => # pause polling when browser knows we're not active/visible/etc.
1095                                 @poll_for_blur_timeout = null
1096                                 if document.activeElement is @outer_iframe
1097                                         @poll_for_blur()
1098                                 else
1099                                         @editor_is_focused = false
1100                                         @onblur()
1101
1102 window.peach_html5_editor = (args...) ->
1103         return new PeachHTML5Editor args...
1104
1105 # test in browser: peach_html5_editor(document.getElementsByTagName('textarea')[0])