JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
mostly working: dedup spaces
[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 TYPE_TAG = peach_parser.TYPE_TAG
18 TYPE_TEXT = peach_parser.TYPE_TEXT
19 TYPE_COMMENT = peach_parser.TYPE_COMMENT
20 TYPE_DOCTYPE = peach_parser.TYPE_DOCTYPE
21
22 debug_dot_at = (doc, x, y) ->
23         el = doc.createElement 'div'
24         el.setAttribute 'style', "position: absolute; left: #{x}px; top: #{y}px; width: 1px; height: 3px; background-color: red"
25         doc.body.appendChild el
26         #console.log(new Error().stack)
27
28 # text nodes don't have getBoundingClientRect(), so use selection api to find
29 # it.
30 get_el_bounds = (el) ->
31         if el.getBoundingClientRect?
32                 rect = el.getBoundingClientRect()
33         else
34                 # text nodes don't have getBoundingClientRect(), so use range api
35                 range = el.ownerDocument.createRange()
36                 range.selectNodeContents el
37                 rect = range.getBoundingClientRect()
38         doc = el.ownerDocument.documentElement
39         win = el.ownerDocument.defaultView
40         y_fix = win.pageYOffset - doc.clientTop
41         x_fix = win.pageXOffset - doc.clientLeft
42         return {
43                 x: rect.left + x_fix
44                 y: rect.top + y_fix
45                 w: rect.width ? (rect.right - rect.left)
46                 h: rect.height ? (rect.top - rect.bottom)
47         }
48
49 # Warning: currently assumes you're asking about a single character
50 # Note: chromium returns multiple bounding rects for a space at a line-break
51 # Note: chromium's getBoundingClientRect() is broken (when zero-area client rects)
52 # Note: sometimes returns null (eg for whitespace that is not visible)
53 text_range_bounds = (el, start, end) ->
54         range = document.createRange()
55         range.setStart el, start
56         range.setEnd el, end
57         rects = range.getClientRects()
58         if rects.length > 0
59                 rect = rects[0]
60         else
61                 return null
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                 rects: rects
72                 bounding: range.getBoundingClientRect()
73         }
74
75 # figure out the x/y coordinates of where the cursor should be if it's at
76 # position ``i`` within text node ``n``
77 # sometimes returns null (eg for whitespace that is not visible)
78 window.cursor_to_xyh = cursor_to_xyh = (n, i) ->
79         range = document.createRange()
80         if n.text.length is 0
81                 ret = text_range_bounds n.el, 0, 0
82         if i is n.text.length
83                 ret = text_range_bounds n.el, i - 1, i
84                 if ret?
85                         ret.x += ret.w
86         else
87                 ret = text_range_bounds n.el, i, i + 1
88         if ret?
89                 debug_dot_at n.el.ownerDocument, ret.x, ret.y
90         return ret
91
92 # encode text so it can be safely placed inside an html attribute
93 enc_attr_regex = new RegExp '(&)|(")|(\u00A0)', 'g'
94 enc_attr = (txt) ->
95         return txt.replace enc_attr_regex, (match, amp, quote) ->
96                 return '&amp;' if (amp)
97                 return '&quot;' if (quote)
98                 return '&nbsp;'
99
100 void_elements = {
101         area: true
102         base: true
103         br: true
104         col: true
105         embed: true
106         hr: true
107         img: true
108         input: true
109         keygen: true
110         link: true
111         meta: true
112         param: true
113         source: true
114         track: true
115         wbr: true
116 }
117 dom_to_html = (dom) ->
118         ret = ''
119         for el in dom
120                 switch el.type
121                         when TYPE_TAG
122                                 ret += '<' + el.name
123                                 attr_keys = []
124                                 for k of el.attrs
125                                         attr_keys.unshift k
126                                 #attr_keys.sort()
127                                 for k in attr_keys
128                                         ret += " #{k}"
129                                         if el.attrs[k].length > 0
130                                                 ret += "=\"#{enc_attr el.attrs[k]}\""
131                                 ret += '>'
132                                 unless void_elements[el.name]
133                                         if el.children.length
134                                                 ret += dom_to_html el.children
135                                         ret += "</#{el.name}>"
136                         when TYPE_TEXT
137                                 ret += el.text
138                         when TYPE_COMMENT
139                                 ret += "<!--#{el.text}-->"
140                         when TYPE_DOCTYPE
141                                 ret += "<!DOCTYPE #{el.name}"
142                                 if el.public_identifier? and el.public_identifier.length > 0
143                                         ret += " \"#{el.public_identifier}\""
144                                 if el.system_identifier? and el.system_identifier.length > 0
145                                         ret += " \"#{el.system_identifier}\""
146                                 ret += ">\n"
147         return ret
148
149 domify = (h) ->
150         for tag, attrs of h
151                 if tag is 'text'
152                         return document.createTextNode attrs
153                 el = document.createElement tag
154                 for k, v of attrs
155                         if k is 'children'
156                                 for child in v
157                                         el.appendChild child
158                         else
159                                 el.setAttribute k, v
160         return el
161
162 css = ''
163 css += 'div#peach_html5_editor_cursor {'
164 css +=     'position: absolute;'
165 css +=     'height: 1em;'
166 css +=     'width: 2px;'
167 css +=     'margin-left: -1px;'
168 css +=     'margin-right: -1px;'
169 css +=     'background: #444;'
170 css +=     '-webkit-animation: blink 1s steps(2, start) infinite;'
171 css +=     'animation: blink 1s steps(2, start) infinite;'
172 css += '}'
173 css += '@-webkit-keyframes blink {'
174 css +=     'to { visibility: hidden; }'
175 css += '}'
176 css += '@keyframes blink {'
177 css +=     'to { visibility: hidden; }'
178 css += '}'
179
180 # key codes:
181 KEY_LEFT = 37
182 KEY_UP = 38
183 KEY_RIGHT = 39
184 KEY_DOWN = 40
185 KEY_BACKSPACE = 8 # <--
186 KEY_DELETE = 46 # -->
187 KEY_END = 35
188 KEY_ENTER = 13
189 KEY_ESCAPE = 27
190 KEY_HOME = 36
191 KEY_INSERT = 45
192 KEY_PAGE_UP = 33
193 KEY_PAGE_DOWN = 34
194 KEY_TAB = 9
195
196 instantiate_tree = (tree, parent) ->
197         for c in tree
198                 switch c.type
199                         when TYPE_TEXT
200                                 c.el = parent.ownerDocument.createTextNode c.text
201                                 parent.appendChild c.el
202                         when TYPE_TAG
203                                 # TODO create in correct namespace
204                                 c.el = parent.ownerDocument.createElement c.name
205                                 for k, v of c.attrs
206                                         # FIXME if attr_whitelist[k]?
207                                         c.el.setAttribute k, v
208                                 parent.appendChild c.el
209                                 if c.children.length
210                                         instantiate_tree c.children, c.el
211
212 traverse_tree = (tree, state, cb) ->
213         for c in tree
214                 cb c, state
215                 break if state.done?
216                 if c.children.length
217                         traverse_tree c.children, state, cb
218                         break if state.done?
219         return state
220 # find the next element in tree (and decendants) that is after n and can contain text
221 # TODO make it so cursor can go places that don't have text but could
222 find_next_cursor_position = (tree, n, i) ->
223         if n? and n.type is TYPE_TEXT and n.text.length > i
224                 orig_xyh = cursor_to_xyh n, i
225                 unless orig_xyh?
226                         console.log "ERROR: couldn't find xy for current cursor location"
227                         return
228                 for next_i in [i+1 .. n.text.length] # inclusive is valid (after last char)
229                         next_xyh = cursor_to_xyh n, next_i
230                         if next_xyh?
231                                 if next_xyh.x > orig_xyh.x or next_xyh.y > orig_xyh.y
232                                         return [n, next_i]
233         found = traverse_tree tree, before: n?, (node, state) ->
234                 if node.type is TYPE_TEXT and state.before is false
235                         state.node = node
236                         state.done = true
237                 if node is n
238                         state.before = false
239         if found.node?
240                 return [found.node, 0]
241         return null
242
243 # TODO make it so cursor can go places that don't have text but could
244 find_prev_cursor_position = (tree, n, i) ->
245         if n? and n.type is TYPE_TEXT and i > 0
246                 orig_xyh = cursor_to_xyh n, i
247                 unless orig_xyh?
248                         console.log "ERROR: couldn't find xy for current cursor location"
249                         return
250                 for prev_i in [i-1 .. 0]
251                         prev_xyh = cursor_to_xyh n, prev_i
252                         if prev_xyh?
253                                 if prev_xyh.x < orig_xyh.x or prev_xyh.y < orig_xyh.y
254                                         return [n, prev_i]
255                 return [n, i - 1]
256         found = traverse_tree tree, before: n?, (node, state) ->
257                 if node.type is TYPE_TEXT
258                         unless n?
259                                 state.node = node
260                                 state.done = true
261                         if node is n
262                                 if state.prev?
263                                         state.node = state.prev
264                                 state.done = true
265                         if node
266                                 state.prev = node
267         if found.node?
268                 return [found.node, found.node.text.length]
269         return null
270
271 find_loc_cursor_position = (tree, loc) ->
272         for c in tree
273                 if c.type is TYPE_TAG or c.type is TYPE_TEXT
274                         bounds = get_el_bounds c.el
275                         continue if loc.x < bounds.x
276                         continue if loc.x > bounds.x + bounds.w
277                         continue if loc.y < bounds.y
278                         continue if loc.y > bounds.y + bounds.h
279                         if c.children.length
280                                 ret = find_loc_cursor_position c.children, loc
281                                 return ret if ret?
282                         if c.type is TYPE_TEXT
283                                 # click is within bounding box that contains all text.
284                                 return [c, 0] if c.text.length is 0
285                                 before_i = 0
286                                 before = cursor_to_xyh c, before_i
287                                 unless before?
288                                         console.log "error: failed to find cursor pixel location for start of", c
289                                         return
290                                 after_i = c.text.length
291                                 after = cursor_to_xyh c, after_i
292                                 unless after?
293                                         console.log "error: failed to find cursor pixel location for end of", c
294                                         return
295                                 if loc.y < before.y + before.h and loc.x < before.x
296                                         # console.log 'before first char on first line'
297                                         continue
298                                 if loc.y > after.y and loc.x > after.x
299                                         # console.log 'after last char on last line'
300                                         continue
301                                 if loc.y < before.y
302                                         console.log "Warning: click in bounding box but above first line"
303                                         continue # above first line (runaround?)
304                                 if loc.y > after.y + after.h
305                                         console.log "Warning: click in bounding box but below last line", loc.y, after.y, after.h
306                                         continue # below last line (shouldn't happen?)
307                                 while after_i - before_i > 1
308                                         cur_i = Math.round((before_i + after_i) / 2)
309                                         cur = cursor_to_xyh c, cur_i
310                                         unless loc?
311                                                 console.log "error: failed to find cursor pixel location for", c, cur_i
312                                                 return
313                                         if loc.y < cur.y or (loc.y <= cur.y + cur.h and loc.x < cur.x)
314                                                 after_i = cur_i
315                                                 after = cur
316                                         else
317                                                 before_i = cur_i
318                                                 before = cur
319                                 # which one is closest?
320                                 if Math.abs(before.x - loc.x) < Math.abs(after.x - loc.x)
321                                         return [c, before_i]
322                                 else
323                                         return [c, after_i]
324         return null
325
326 # browsers collapse these (html5 spec calls these "space characters")
327 is_space_code = (char_code) ->
328         switch char_code
329                 when 9, 10, 12, 13, 32
330                         return true
331         return false
332 is_space = (chr) ->
333         return is_space_code chr.charCodeAt 0
334
335 # warning: contains browser-specific hackery
336 is_space_significant = (n, i) ->
337         range = document.createRange()
338         range.setStart n.el, i
339         range.setEnd n.el, i + 1
340         rects = range.getClientRects()
341         bounding_rect = range.getBoundingClientRect()
342         if rects.length is 0
343                 return false
344         if rects.length > 1
345                 # chromium returns two rects in both these cases:
346                 # 1. a space that is word-wrapped. one rect on each line. Note that
347                 #    chromium does _not_ do this for _all_ spaces that are word wrapped.
348                 # 2. the last (insignificant) space in a sequence of collapsing spaces
349                 #    in this case the rects are identical.
350                 if rects[1].top > rects[0].top
351                         return true
352         width = rects[0].width ? (rects[0].right - rects[0].left)
353         if width > 0
354                 return true
355         # firefox reports the space that's word-wrapped as zero width
356         if n.text.length > i + 1
357                 range.setStart n.el, i + 1
358                 range.setEnd n.el, i + 2
359                 next_rects = range.getClientRects()
360                 if next_rects.length > 0
361                         if next_rects[0].top > rects[0].top
362                                 # next character is lower on the screen, so this must be a word-wrap space
363                                 return true
364         else
365                 # FIXME detect word-wrap in last character
366                 # could be followed by an inline block with no starting space
367         # FIXME chromium gets here for a significant space at the begining of a
368         # text node that word-wraps
369         return false
370
371 # pass a node (from parser library, ie it should have .el and .text)
372 remove_insignificant_whitespace = (n) ->
373         changed = false
374         if n.type is TYPE_TEXT
375                 i = 0
376                 while i < n.text.length
377                         if is_space_code n.text.charCodeAt i
378                                 if is_space_significant n, i
379                                         i += 1
380                                 else
381                                         n.el.textContent = n.text = (n.text.substr 0, i) + (n.text.substr i + 1)
382                                         changed = true
383                         else
384                                 i += 1
385         if n.children.length > 0
386                 for c in n.children
387                         if remove_insignificant_whitespace c
388                                 changed = true
389         return changed
390
391 class PeachHTML5Editor
392         constructor: (in_el, options = {}) ->
393                 @in_el = in_el
394                 @tree = []
395                 @iframe = domify iframe: class: 'peach_html5_editor'
396                 @cursor = null
397                 @cursor_el = null
398                 @cursor_visible = false
399                 opt_fragment = options.fragment ? true
400                 @parser_opts = {}
401                 if opt_fragment
402                         @parser_opts.fragment = 'body'
403
404                 @iframe.onload = =>
405                         @idoc = @iframe.contentDocument
406
407                         ignore_key_codes =
408                                 '18': true # alt
409                                 '20': true # capslock
410                                 '17': true # ctrl
411                                 '144': true # numlock
412                                 '16': true # shift
413                                 '91': true # windows "start" key
414                         control_key_codes = # we react to these, but they aren't typing
415                                 '37': KEY_LEFT
416                                 '38': KEY_UP
417                                 '39': KEY_RIGHT
418                                 '40': KEY_DOWN
419                                 '35': KEY_END
420                                 '8':  KEY_BACKSPACE
421                                 '46': KEY_DELETE
422                                 '13': KEY_ENTER
423                                 '27': KEY_ESCAPE
424                                 '36': KEY_HOME
425                                 '45': KEY_INSERT
426                                 '33': KEY_PAGE_UP
427                                 '34': KEY_PAGE_DOWN
428                                 '9':  KEY_TAB
429
430                         @idoc.body.onclick = (e) =>
431                                 # idoc.body.offset().left/top
432                                 new_cursor = find_loc_cursor_position @tree, x: e.pageX, y: e.pageY
433                                 if new_cursor?
434                                         @move_cursor new_cursor
435                         @idoc.body.onkeyup = (e) =>
436                                 return if e.ctrlKey
437                                 return false if ignore_key_codes[e.keyCode]?
438                                 #return false if control_key_codes[e.keyCode]?
439                         @idoc.body.onkeydown = (e) =>
440                                 return if e.ctrlKey
441                                 return false if ignore_key_codes[e.keyCode]?
442                                 #return false if control_key_codes[e.keyCode]?
443                                 switch e.keyCode
444                                         when KEY_LEFT
445                                                 if @cursor?
446                                                         new_cursor = find_prev_cursor_position @tree, @cursor...
447                                                         if new_cursor?
448                                                                 @move_cursor new_cursor
449                                                 else
450                                                         for c in @tree
451                                                                 new_cursor = find_next_cursor_position @tree, c, -1
452                                                                 if new_cursor?
453                                                                         @move_cursor new_cursor
454                                                                         break
455                                                 return false
456                                         when KEY_UP
457                                                 return false
458                                         when KEY_RIGHT
459                                                 if @cursor?
460                                                         new_cursor = find_next_cursor_position @tree, @cursor...
461                                                         if new_cursor?
462                                                                 @move_cursor new_cursor
463                                                 else
464                                                         for c in @tree
465                                                                 new_cursor = find_prev_cursor_position @tree, c, -1
466                                                                 if new_cursor?
467                                                                         @move_cursor new_cursor
468                                                                         break
469                                                 return false
470                                         when KEY_DOWN
471                                                 return false
472                                         when KEY_END
473                                                 return false
474                                         when KEY_BACKSPACE
475                                                 return false unless @cursor?
476                                                 return false unless @cursor[1] > 0
477                                                 @cursor[0].text = @cursor[0].text.substr(0, @cursor[1] - 1) + @cursor[0].text.substr(@cursor[1])
478                                                 @cursor[0].el.nodeValue = @cursor[0].text
479                                                 @move_cursor [@cursor[0], @cursor[1] - 1]
480                                                 return false
481                                         when KEY_DELETE
482                                                 return false unless @cursor?
483                                                 return false unless @cursor[1] < @cursor[0].text.length
484                                                 @cursor[0].text = @cursor[0].text.substr(0, @cursor[1]) + @cursor[0].text.substr(@cursor[1] + 1)
485                                                 @cursor[0].el.nodeValue = @cursor[0].text
486                                                 @move_cursor [@cursor[0], @cursor[1]]
487                                                 return false
488                                         when KEY_ENTER
489                                                 return false
490                                         when KEY_ESCAPE
491                                                 return false
492                                         when KEY_HOME
493                                                 return false
494                                         when KEY_INSERT
495                                                 return false
496                                         when KEY_PAGE_UP
497                                                 return false
498                                         when KEY_PAGE_DOWN
499                                                 return false
500                                         when KEY_TAB
501                                                 return false
502                         @idoc.body.onkeypress = (e) =>
503                                 return if e.ctrlKey
504                                 return false if ignore_key_codes[e.keyCode]?
505                                 return false if control_key_codes[e.keyCode]? # handled in keydown
506                                 char = e.charCode ? e.keyCode
507                                 if char and @cursor?
508                                         char = String.fromCharCode char
509                                         if @cursor[1] is 0
510                                                 @cursor[0].text = char + @cursor[0].text
511                                         else if @cursor[1] is @cursor[0].text.length - 1
512                                                 @cursor[0].text += char
513                                         else
514                                                 @cursor[0].text =
515                                                         @cursor[0].text.substr(0, @cursor[1]) +
516                                                         char +
517                                                         @cursor[0].text.substr(@cursor[1])
518                                         @cursor[0].el.nodeValue = @cursor[0].text
519                                         @move_cursor [@cursor[0], @cursor[1] + 1]
520                                         @changed()
521                                 return false
522                         if options.stylesheet # TODO test this
523                                 istyle = @idoc.createElement 'style'
524                                 istyle.setAttribute 'src', options.stylesheet
525                                 @idoc.head.appendChild istyle
526                         icss = @idoc.createElement 'style'
527                         icss.appendChild @idoc.createTextNode css
528                         @idoc.head.appendChild icss
529                         @load_html @in_el.value
530
531                 @in_el.parentNode.appendChild @iframe
532         clear_dom: ->
533                 # FIXME add parent node, so we don't empty body and delete cursor_el
534                 while @idoc.body.childNodes.length
535                         @idoc.body.removeChild @idoc.body.childNodes[0]
536                 @cursor_visible = false
537                 return
538         load_html: (html) ->
539                 @tree = peach_parser.parse html, @parser_opts
540                 @clear_dom()
541                 instantiate_tree @tree, @idoc.body
542                 remove_insignificant_whitespace type: TYPE_TAG, children: @tree
543                 @changed()
544         changed: ->
545                 # FIXME don't export cursor placeholder (when cursor is between space characters)
546                 @in_el.onchange = null
547                 @in_el.value = dom_to_html @tree
548                 @in_el.onchange = =>
549                         @load_html @in_el.value
550         move_cursor: (cursor) ->
551                 loc = cursor_to_xyh cursor[0], cursor[1]
552                 unless loc?
553                         console.log "error: tried to move cursor to position that has no pixel location", cursor[0], cursor[1]
554                         return
555                 @cursor = cursor
556                 # replace cursor, to reset blink animation
557                 if @cursor_visible
558                         @cursor_el.parentNode.removeChild @cursor_el
559                 @cursor_el = domify div: id: 'peach_html5_editor_cursor'
560                 @idoc.body.appendChild @cursor_el
561                 @cursor_visible = true
562                 # TODO figure out x,y coords for cursor
563                 @cursor_el.style.left = "#{loc.x}px"
564                 @cursor_el.style.top = "#{loc.y}px"
565
566 window.peach_html5_editor = (args...) ->
567         return new PeachHTML5Editor args...
568
569 # test in browser: peach_html5_editor(document.getElementsByTagName('textarea')[0])