JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
fix new positioning code for scrolled editor
[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_text_bounding_rect = (el) ->
31 get_el_bounds = (el) ->
32         if el.getBoundingClientRect?
33                 rect = el.getBoundingClientRect()
34         else
35                 # text nodes don't have getBoundingClientRect(), so use range api
36                 range = el.ownerDocument.createRange()
37                 range.selectNodeContents el
38                 rect = range.getBoundingClientRect()
39         doc = el.ownerDocument.documentElement
40         win = el.ownerDocument.defaultView
41         y_fix = win.pageYOffset - doc.clientTop
42         x_fix = win.pageXOffset - doc.clientLeft
43         return {
44                 x: rect.left + x_fix
45                 y: rect.top + y_fix
46                 w: rect.width ? (rect.right - rect.left)
47                 h: rect.height ? (rect.top - rect.bottom)
48         }
49
50 # figure out the x/y coordinates of where the cursor should be if it's at
51 # position ``i`` within text node ``n``
52 window.cursor_to_xyh = cursor_to_xyh = (n, i) ->
53         range = document.createRange()
54         plus_width = false
55         if n.text.length is 0
56                 range.setStart n.el, i
57                 range.setEnd n.el, i
58         if i is n.text.length
59                 range.setStart n.el, i - 1
60                 range.setEnd n.el, i
61                 plus_width = true
62         else
63                 range.setStart n.el, i
64                 range.setEnd n.el, i + 1
65         # chromium returns bogus results with getBoundingClientRect() when zero width and/or height
66         rect = range.getClientRects()
67         if rect.length > 0
68                 if plus_width
69                         # chromium returns multiple rects for the space that is broken
70                         # across lines by word-wrap (firefox doesn't)
71                         rect = rect[rect.length - 1]
72                 else
73                         rect = rect[0]
74         else
75                 return x: 0, y: 0, w: 0, h: 0 # TODO return null, fix callers
76         doc = n.el.ownerDocument.documentElement
77         win = n.el.ownerDocument.defaultView
78         y_fix = win.pageYOffset - doc.clientTop
79         x_fix = win.pageXOffset - doc.clientLeft
80         ret = {
81                 x: rect.left + x_fix
82                 y: rect.top + y_fix
83                 w: rect.width ? (rect.right - rect.left)
84                 h: rect.height ? (rect.top - rect.bottom)
85         }
86         if plus_width
87                 ret.x += ret.w
88         debug_dot_at n.el.ownerDocument, ret.x, ret.y
89         return ret
90
91 # encode text so it can be safely placed inside an html attribute
92 enc_attr_regex = new RegExp '(&)|(")|(\u00A0)', 'g'
93 enc_attr = (txt) ->
94         return txt.replace enc_attr_regex, (match, amp, quote) ->
95                 return '&amp;' if (amp)
96                 return '&quot;' if (quote)
97                 return '&nbsp;'
98
99 void_elements = {
100         area: true
101         base: true
102         br: true
103         col: true
104         embed: true
105         hr: true
106         img: true
107         input: true
108         keygen: true
109         link: true
110         meta: true
111         param: true
112         source: true
113         track: true
114         wbr: true
115 }
116 dom_to_html = (dom) ->
117         ret = ''
118         for el in dom
119                 switch el.type
120                         when TYPE_TAG
121                                 ret += '<' + el.name
122                                 attr_keys = []
123                                 for k of el.attrs
124                                         attr_keys.unshift k
125                                 #attr_keys.sort()
126                                 for k in attr_keys
127                                         ret += " #{k}"
128                                         if el.attrs[k].length > 0
129                                                 ret += "=\"#{enc_attr el.attrs[k]}\""
130                                 ret += '>'
131                                 unless void_elements[el.name]
132                                         if el.children.length
133                                                 ret += dom_to_html el.children
134                                         ret += "</#{el.name}>"
135                         when TYPE_TEXT
136                                 ret += el.text
137                         when TYPE_COMMENT
138                                 ret += "<!--#{el.text}-->"
139                         when TYPE_DOCTYPE
140                                 ret += "<!DOCTYPE #{el.name}"
141                                 if el.public_identifier? and el.public_identifier.length > 0
142                                         ret += " \"#{el.public_identifier}\""
143                                 if el.system_identifier? and el.system_identifier.length > 0
144                                         ret += " \"#{el.system_identifier}\""
145                                 ret += ">\n"
146         return ret
147
148 domify = (h) ->
149         for tag, attrs of h
150                 if tag is 'text'
151                         return document.createTextNode attrs
152                 el = document.createElement tag
153                 for k, v of attrs
154                         if k is 'children'
155                                 for child in v
156                                         el.appendChild child
157                         else
158                                 el.setAttribute k, v
159         return el
160
161 css = ''
162 css += 'div#peach_html5_editor_cursor {'
163 css +=     'position: absolute;'
164 css +=     'height: 1em;'
165 css +=     'width: 2px;'
166 css +=     'margin-left: -1px;'
167 css +=     'margin-right: -1px;'
168 css +=     'background: #444;'
169 css +=     '-webkit-animation: blink 1s steps(2, start) infinite;'
170 css +=     'animation: blink 1s steps(2, start) infinite;'
171 css += '}'
172 css += '@-webkit-keyframes blink {'
173 css +=     'to { visibility: hidden; }'
174 css += '}'
175 css += '@keyframes blink {'
176 css +=     'to { visibility: hidden; }'
177 css += '}'
178
179 # key codes:
180 KEY_LEFT = 37
181 KEY_UP = 38
182 KEY_RIGHT = 39
183 KEY_DOWN = 40
184 KEY_BACKSPACE = 8 # <--
185 KEY_DELETE = 46 # -->
186 KEY_END = 35
187 KEY_ENTER = 13
188 KEY_ESCAPE = 27
189 KEY_HOME = 36
190 KEY_INSERT = 45
191 KEY_PAGE_UP = 33
192 KEY_PAGE_DOWN = 34
193 KEY_TAB = 9
194
195 instantiate_tree = (tree, parent) ->
196         for c in tree
197                 switch c.type
198                         when TYPE_TEXT
199                                 c.el = parent.ownerDocument.createTextNode c.text
200                                 parent.appendChild c.el
201                         when TYPE_TAG
202                                 # TODO create in correct namespace
203                                 c.el = parent.ownerDocument.createElement c.name
204                                 for k, v of c.attrs
205                                         # FIXME if attr_whitelist[k]?
206                                         c.el.setAttribute k, v
207                                 parent.appendChild c.el
208                                 if c.children.length
209                                         instantiate_tree c.children, c.el
210
211 traverse_tree = (tree, state, cb) ->
212         for c in tree
213                 cb c, state
214                 break if state.done?
215                 if c.children.length
216                         traverse_tree c.children, state, cb
217                         break if state.done?
218         return state
219 # find the next element in tree (and decendants) that is after n and can contain text
220 # TODO make it so cursor can go places that don't have text but could
221 find_next_cursor_position = (tree, n, i) ->
222         if n? and n.type is TYPE_TEXT and n.text.length > i
223                 orig_xyh = cursor_to_xyh n, i
224                 for next_i in [i+1 .. n.text.length] # inclusive is valid (after last char)
225                         next_xyh = cursor_to_xyh n, next_i
226                         if next_xyh.x > orig_xyh.x or next_xyh.y > orig_xyh.y
227                                 return [n, next_i]
228         found = traverse_tree tree, before: n?, (node, state) ->
229                 if node.type is TYPE_TEXT and state.before is false
230                         state.node = node
231                         state.done = true
232                 if node is n
233                         state.before = false
234         if found.node?
235                 return [found.node, 0]
236         return null
237
238 # TODO make it so cursor can go places that don't have text but could
239 find_prev_cursor_position = (tree, n, i) ->
240         if n? and n.type is TYPE_TEXT and i > 0
241                 orig_xyh = cursor_to_xyh n, i
242                 for prev_i in [i-1 .. 0]
243                         prev_xyh = cursor_to_xyh n, prev_i
244                         if prev_xyh.x < orig_xyh.x or prev_xyh.y < orig_xyh.y
245                                 return [n, prev_i]
246                 return [n, i - 1]
247         found = traverse_tree tree, before: n?, (node, state) ->
248                 if node.type is TYPE_TEXT
249                         unless n?
250                                 state.node = node
251                                 state.done = true
252                         if node is n
253                                 if state.prev?
254                                         state.node = state.prev
255                                 state.done = true
256                         if node
257                                 state.prev = node
258         if found.node?
259                 return [found.node, found.node.text.length]
260         return null
261
262 find_loc_cursor_position = (tree, loc) ->
263         for c in tree
264                 if c.type is TYPE_TAG or c.type is TYPE_TEXT
265                         bounds = get_el_bounds c.el
266                         continue if loc.x < bounds.x
267                         continue if loc.x > bounds.x + bounds.w
268                         continue if loc.y < bounds.y
269                         continue if loc.y > bounds.y + bounds.h
270                         if c.children.length
271                                 ret = find_loc_cursor_position c.children, loc
272                                 return ret if ret?
273                         if c.type is TYPE_TEXT
274                                 # click is within bounding box that contains all text.
275                                 return [c, 0] if c.text.length is 0
276                                 before_i = 0
277                                 before = cursor_to_xyh c, before_i
278                                 after_i = c.text.length
279                                 after = cursor_to_xyh c, after_i
280                                 if loc.y < before.y + before.h and loc.x < before.x
281                                         # console.log 'before first char on first line'
282                                         continue
283                                 if loc.y > after.y and loc.x > after.x
284                                         # console.log 'after last char on last line'
285                                         continue
286                                 if loc.y < before.y
287                                         console.log "Warning: click in bounding box but above first line"
288                                         continue # above first line (runaround?)
289                                 if loc.y > after.y + after.h
290                                         console.log "Warning: click in bounding box but below last line", loc.y, after.y, after.h
291                                         continue # below last line (shouldn't happen?)
292                                 while after_i - before_i > 1
293                                         cur_i = Math.round((before_i + after_i) / 2)
294                                         cur = cursor_to_xyh c, cur_i
295                                         if loc.y < cur.y or (loc.y <= cur.y + cur.h and loc.x < cur.x)
296                                                 after_i = cur_i
297                                                 after = cur
298                                         else
299                                                 before_i = cur_i
300                                                 before = cur
301                                 # which one is closest?
302                                 if Math.abs(before.x - loc.x) < Math.abs(after.x - loc.x)
303                                         return [c, before_i]
304                                 else
305                                         return [c, after_i]
306         return null
307
308 class PeachHTML5Editor
309         constructor: (in_el, options = {}) ->
310                 @in_el = in_el
311                 @tree = []
312                 @iframe = domify iframe: class: 'peach_html5_editor'
313                 @cursor = null
314                 @cursor_el = null
315                 @cursor_visible = false
316                 opt_fragment = options.fragment ? true
317                 @parser_opts = {}
318                 if opt_fragment
319                         @parser_opts.fragment = 'body'
320
321                 @iframe.onload = =>
322                         @idoc = @iframe.contentDocument
323
324                         ignore_key_codes =
325                                 '18': true # alt
326                                 '20': true # capslock
327                                 '17': true # ctrl
328                                 '144': true # numlock
329                                 '16': true # shift
330                                 '91': true # windows "start" key
331                         control_key_codes = # we react to these, but they aren't typing
332                                 '37': KEY_LEFT
333                                 '38': KEY_UP
334                                 '39': KEY_RIGHT
335                                 '40': KEY_DOWN
336                                 '35': KEY_END
337                                 '8':  KEY_BACKSPACE
338                                 '46': KEY_DELETE
339                                 '13': KEY_ENTER
340                                 '27': KEY_ESCAPE
341                                 '36': KEY_HOME
342                                 '45': KEY_INSERT
343                                 '33': KEY_PAGE_UP
344                                 '34': KEY_PAGE_DOWN
345                                 '9':  KEY_TAB
346
347                         @idoc.body.onclick = (e) =>
348                                 # idoc.body.offset().left/top
349                                 new_cursor = find_loc_cursor_position @tree, x: e.pageX, y: e.pageY
350                                 if new_cursor?
351                                         @move_cursor new_cursor
352                         @idoc.body.onkeyup = (e) =>
353                                 return if e.ctrlKey
354                                 return false if ignore_key_codes[e.keyCode]?
355                                 #return false if control_key_codes[e.keyCode]?
356                         @idoc.body.onkeydown = (e) =>
357                                 return if e.ctrlKey
358                                 return false if ignore_key_codes[e.keyCode]?
359                                 #return false if control_key_codes[e.keyCode]?
360                                 switch e.keyCode
361                                         when KEY_LEFT
362                                                 if @cursor?
363                                                         new_cursor = find_prev_cursor_position @tree, @cursor...
364                                                         if new_cursor?
365                                                                 @move_cursor new_cursor
366                                                 else
367                                                         for c in @tree
368                                                                 new_cursor = find_next_cursor_position @tree, c, -1
369                                                                 if new_cursor?
370                                                                         @move_cursor new_cursor
371                                                                         break
372                                                 return false
373                                         when KEY_UP
374                                                 return false
375                                         when KEY_RIGHT
376                                                 if @cursor?
377                                                         new_cursor = find_next_cursor_position @tree, @cursor...
378                                                         if new_cursor?
379                                                                 @move_cursor new_cursor
380                                                 else
381                                                         for c in @tree
382                                                                 new_cursor = find_prev_cursor_position @tree, c, -1
383                                                                 if new_cursor?
384                                                                         @move_cursor new_cursor
385                                                                         break
386                                                 return false
387                                         when KEY_DOWN
388                                                 return false
389                                         when KEY_END
390                                                 return false
391                                         when KEY_BACKSPACE
392                                                 return false unless @cursor?
393                                                 return false unless @cursor[1] > 0
394                                                 @cursor[0].text = @cursor[0].text.substr(0, @cursor[1] - 1) + @cursor[0].text.substr(@cursor[1])
395                                                 @cursor[0].el.nodeValue = @cursor[0].text
396                                                 @move_cursor [@cursor[0], @cursor[1] - 1]
397                                                 return false
398                                         when KEY_DELETE
399                                                 return false unless @cursor?
400                                                 return false unless @cursor[1] < @cursor[0].text.length
401                                                 @cursor[0].text = @cursor[0].text.substr(0, @cursor[1]) + @cursor[0].text.substr(@cursor[1] + 1)
402                                                 @cursor[0].el.nodeValue = @cursor[0].text
403                                                 @move_cursor [@cursor[0], @cursor[1]]
404                                                 return false
405                                         when KEY_ENTER
406                                                 return false
407                                         when KEY_ESCAPE
408                                                 return false
409                                         when KEY_HOME
410                                                 return false
411                                         when KEY_INSERT
412                                                 return false
413                                         when KEY_PAGE_UP
414                                                 return false
415                                         when KEY_PAGE_DOWN
416                                                 return false
417                                         when KEY_TAB
418                                                 return false
419                         @idoc.body.onkeypress = (e) =>
420                                 return if e.ctrlKey
421                                 return false if ignore_key_codes[e.keyCode]?
422                                 return false if control_key_codes[e.keyCode]? # handled in keydown
423                                 char = e.charCode ? e.keyCode
424                                 if char and @cursor?
425                                         char = String.fromCharCode char
426                                         if @cursor[1] is 0
427                                                 @cursor[0].text = char + @cursor[0].text
428                                         else if @cursor[1] is @cursor[0].text.length - 1
429                                                 @cursor[0].text += char
430                                         else
431                                                 @cursor[0].text =
432                                                         @cursor[0].text.substr(0, @cursor[1]) +
433                                                         char +
434                                                         @cursor[0].text.substr(@cursor[1])
435                                         @cursor[0].el.nodeValue = @cursor[0].text
436                                         @move_cursor [@cursor[0], @cursor[1] + 1]
437                                 return false
438                         if options.stylesheet # TODO test this
439                                 istyle = @idoc.createElement 'style'
440                                 istyle.setAttribute 'src', options.stylesheet
441                                 @idoc.head.appendChild istyle
442                         icss = @idoc.createElement 'style'
443                         icss.appendChild @idoc.createTextNode css
444                         @idoc.head.appendChild icss
445                         @load_html @in_el.value
446
447                 @in_el.parentNode.appendChild @iframe
448         clear_dom: ->
449                 # FIXME add parent node, so we don't empty body and delete cursor_el
450                 while @idoc.body.childNodes.length
451                         @idoc.body.removeChild @idoc.body.childNodes[0]
452                 @cursor_visible = false
453                 return
454         load_html: (html) ->
455                 @tree = peach_parser.parse html, @parser_opts
456                 #as_html = dom_to_html @tree
457                 #@iframe.contentDocument.body.innerHTML = as_html
458                 @clear_dom()
459                 instantiate_tree @tree, @idoc.body
460         move_cursor: (cursor) ->
461                 loc = cursor_to_xyh cursor[0], cursor[1]
462                 return if loc is null
463                 @cursor = cursor
464                 # replace cursor, to reset blink animation
465                 if @cursor_visible
466                         @cursor_el.parentNode.removeChild @cursor_el
467                 @cursor_el = domify div: id: 'peach_html5_editor_cursor'
468                 @idoc.body.appendChild @cursor_el
469                 @cursor_visible = true
470                 # TODO figure out x,y coords for cursor
471                 @cursor_el.style.left = "#{loc.x}px"
472                 @cursor_el.style.top = "#{loc.y}px"
473
474 window.peach_html5_editor = (args...) ->
475         return new PeachHTML5Editor args...
476
477 # test in browser: peach_html5_editor(document.getElementsByTagName('textarea')[0])