ret = ret.substr 0, clip + 1
return ret
+# table too look up the properties of various values for css's white-space
+ws_props =
+ normal:
+ space: false # spaces are not preserved/rendered
+ newline: false # newlines are not preserved/rendered
+ wrap: true # text is word-wrapped
+ to_preserve: 'pre-wrap' # to preservespaces, change white-space to this
+ nowrap:
+ space: false
+ newline: false
+ wrap: false
+ to_preserve: 'pre'
+ 'pre-line':
+ space: false
+ newline: true
+ wrap: true
+ to_preserve: 'pre-wrap'
+ pre:
+ space: true
+ newline: true
+ wrap: false
+ 'pre-wrap':
+ space: true
+ newline: true
+ wrap: true
+
# xml 1.0 spec, chromium and firefox accept these, plus lots of unicode chars
valid_attr_regex = new RegExp '^[a-zA-Z_:][-a-zA-Z0-9_:.]*$'
# html5 spec is much more lax, but chromium won't let me make at attribute with the name "4"
range.setEnd el, end
rects = range.getClientRects()
if rects.length > 0
- rect = rects[0]
+ if rects.length > 1
+ if rects[1].width > rects[0].width
+ rect = rects[1]
+ else
+ rect = rects[0]
+ else
+ rect = rects[0]
else
return null
doc = el.ownerDocument.documentElement
el.setAttribute k, v
return el
-outer_css = (args) ->
- w = args.w ? 300
- h = args.h ? 300
- inner_padding = args.inner_padding ? overlay_padding
- frame_width = args.frame_width ? inner_padding
- # TODO editor controls height...
- occupy = (left, top = left, right = left, bottom = top) ->
- w -= left + right
- h -= top + bottom
- return Math.max(left, top, right, bottom)
- ret = ''
- ret += 'body {'
- ret += 'margin: 0;'
- ret += 'padding: 0;'
- ret += 'color: black;'
- ret += 'background: white;'
- ret += '}'
- ret += '#wrap1 {'
- ret += "border: #{occupy 1}px solid black;"
- ret += "padding: #{occupy frame_width}px;"
- ret += '}'
- ret += '#wrap2 {'
- ret += "border: #{occupy 1}px solid black;"
- ret += "padding: #{occupy inner_padding}px;"
- ret += "padding-right: #{inner_padding + occupy 0, 0, 15, 0}px;" # for scroll bar
- ret += "width: #{w}px;"
- ret += "height: #{h}px;"
- ret += 'overflow-x: hidden;'
- ret += 'overflow-y: scroll;'
- ret += '}'
- ret += '#wrap3 {'
- ret += 'position: relative;'
- ret += "width: #{w}px;"
- ret += "min-height: #{h}px;"
- ret += '}'
- ret += 'iframe {'
- ret += 'box-sizing: border-box;'
- ret += 'margin: 0;'
- ret += 'border: none;'
- ret += 'padding: 0;'
- ret += "width: #{w}px;"
- #ret += "height: #{h}px;" # height auto-set when content set/changed
- ret += '-ms-user-select: none;'
- ret += '-webkit-user-select: none;'
- ret += '-moz-user-select: none;'
- ret += 'user-select: none;'
- ret += '}'
- ret += '#overlay {'
- ret += 'position: absolute;'
- ret += "left: -#{inner_padding}px;"
- ret += "top: -#{inner_padding}px;"
- ret += "right: -#{inner_padding}px;"
- ret += "bottom: -#{inner_padding}px;"
- ret += 'overflow: hidden;'
- ret += '}'
- ret += '.lightbox {'
- ret += 'position: absolute;'
- ret += 'background: rgba(100,100,100,0.2);'
- ret += '}'
- ret += '#cursor {'
- ret += 'position: absolute;'
- ret += 'width: 2px;'
- ret += 'background: linear-gradient(0deg, rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1));'
- ret += 'background-size: 200% 200%;'
- ret += '-webkit-animation: blink 1s linear normal infinite;'
- ret += 'animation: blink 1s linear normal infinite;'
- ret += '}'
- ret += '@-webkit-keyframes blink {'
- ret += '0%{background-position:0% 0%}'
- ret += '100%{background-position:0% -100%}'
- ret += '}'
- ret += '@keyframes blink { '
- ret += '0%{background-position:0% 0%}'
- ret += '100%{background-position:0% -100%}'
- ret += '}'
- ret += '.ann_box {'
- ret += 'z-index: 5;'
- ret += 'position: absolute;'
- ret += 'border: 1px solid rgba(0,0,0,0.1);'
- ret += 'outline: 1px solid rgba(255,255,255,0.1);' # in case there's a black background
- ret += '}'
- ret += '.ann_tag {'
- ret += 'z-index: 10;'
- ret += 'position: absolute;'
- ret += 'font-size: 8px;'
- ret += 'white-space: pre;'
- ret += 'background: rgba(255,255,255,0.4);'
- ret += '-ms-user-select: none;'
- ret += '-webkit-user-select: none;'
- ret += '-moz-user-select: none;'
- ret += 'user-select: none;'
- ret += '}'
- return ret
ignore_key_codes =
return false
return found # maybe null
+find_up_cursor_position = (tree, cursor, ideal_x) ->
+ new_cursor = cursor
+ # go prev until we're higher on y axis
+ while new_cursor.y >= cursor.y
+ new_cursor = find_prev_cursor_position tree, new_cursor
+ return null unless new_cursor?
+ # done early if we're already left of old cursor position
+ if new_cursor.x <= ideal_x
+ return new_cursor
+ target_y = new_cursor.y
+ # search leftward, until we find the closest position
+ # new_cursor is the prev-most position we've checked
+ # prev_cursor is the older value, so it's not as prev as new_cursor
+ while new_cursor.x > ideal_x and new_cursor.y is target_y
+ prev_cursor = new_cursor
+ new_cursor = find_prev_cursor_position tree, new_cursor
+ break unless new_cursor?
+ # move cursor to prev_cursor or new_cursor
+ if new_cursor?
+ if new_cursor.y is target_y
+ # both valid, and on the same line, use closest
+ if (ideal_x - new_cursor.x) < (prev_cursor.x - ideal_x)
+ return new_cursor
+ else
+ return prev_cursor
+ else
+ # new_cursor on wrong line, use prev_cursor
+ return prev_cursor
+ else
+ # can't go any further prev, use prev_cursor
+ return prev_cursor
+
+find_down_cursor_position = (tree, cursor, ideal_x) ->
+ new_cursor = cursor
+ # go next until we move on the y axis
+ while new_cursor.y <= cursor.y
+ new_cursor = find_next_cursor_position tree, new_cursor
+ return null unless new_cursor?
+ # done early if we're already right of old cursor position
+ if new_cursor.x >= ideal_x
+ # this would be strange, but could happen due to runaround
+ return new_cursor
+ target_y = new_cursor.y
+ # search rightward, until we find the closest position
+ # new_cursor is the next-most position we've checked
+ # prev_cursor is the older value, so it's not as next as new_cursor
+ while new_cursor.x < ideal_x and new_cursor.y is target_y
+ prev_cursor = new_cursor
+ new_cursor = find_next_cursor_position tree, new_cursor
+ break unless new_cursor?
+ # move cursor to prev_cursor or new_cursor
+ if new_cursor?
+ if new_cursor.y is target_y
+ # both valid, and on the same line, use closest
+ if (new_cursor.x - ideal_x) < (ideal_x - prev_cursor.x)
+ return new_cursor
+ else
+ return prev_cursor
+ else
+ # new_cursor on wrong line, use prev_cursor
+ return prev_cursor
+ else
+ # can't go any further prev, use prev_cursor
+ return prev_cursor
+
xy_to_cursor = (tree, xy) ->
for n in tree
if n.type is 'tag' or n.type is 'text'
if block
cb null
# remove cur char
- remove = ->
- removed_char = cur.text.charAt(cur_i)
- cur.el.textContent = cur.text = (cur.text.substr 0, cur_i) + (cur.text.substr cur_i + 1)
- if next is cur # in same text node
- if next_i is 0
- throw "how is this possible?"
- next_i -= 1
- return true
- # undo remove()
- put_it_back = ->
- cur.el.textContent = cur.text = (cur.text.substr 0, cur_i) + removed_char + (cur.text.substr cur_i)
- if next is cur # in same text node
- next_i += 1
- return false
+ remove = (undo) ->
+ if undo
+ cur.el.textContent = cur.text = (cur.text.substr 0, cur_i) + removed_char + (cur.text.substr cur_i)
+ if next is cur # in same text node
+ next_i += 1
+ return -1
+ else
+ removed_char = cur.text.charAt(cur_i)
+ cur.el.textContent = cur.text = (cur.text.substr 0, cur_i) + (cur.text.substr cur_i + 1)
+ if next is cur # in same text node
+ if next_i is 0
+ throw "how is this possible?"
+ next_i -= 1
+ return 1
+ whitespace_to_space = (undo) ->
+ if undo
+ cur.text = (cur.text.substr 0, cur_i) + removed_char + (cur.text.substr cur_i + 1)
+ cur.el.textContent = cur.text
+ else
+ removed_char = cur.text.charAt(cur_i)
+ cur.text = (cur.text.substr 0, cur_i) + ' ' + (cur.text.substr cur_i + 1)
+ cur.el.textContent = cur.text
+ return 0
# return true if cur was removed from the dom (ie re-use same prev)
operate = ->
# cur definitately set
# prev and/or next might be null, indicating the start/end of a display:block
return false unless is_space_code cur.text.charCodeAt cur_i
+ fixers = [remove, whitespace_to_space]
bounds = text_range_bounds cur.el, cur_i, cur_i + 1
# consistent cases:
# 1. zero rects returned by getClientRects() means collapsed space
return remove()
# 2. width greater than zero means visible space
if bounds.w > 0
- return false
+ fixers.shift() # don't try removing
# now the weird edge cases...
#
# firefox and chromium both report zero width for characters at the end
next_px = new_cursor_position n: next, i: next_i
#if prev is null and next is null
# parent_px = cur.parent.el.getBoundingClientRect()
- remove()
- if prev?
- if prev_px?
- new_prev_px = new_cursor_position n: prev, i: prev_i
- if new_prev_px.x isnt prev_px.x or new_prev_px.y isnt prev_px.y
- return put_it_back()
- else
- console.log "this shouldn't happen, we remove spaces that don't locate"
- if next?
- if next_px?
- new_next_px = new_cursor_position n: next, i: next_i
- if new_next_px.x isnt next_px.x or new_next_px.y isnt next_px.y
- return put_it_back()
- #else
- # console.log "removing space becase space after it is collapsed"
- return true
+ undo_arg = true # just for readabality
+ removed = 0
+ for fixer in fixers
+ break if removed > 0
+ removed += fixer()
+ need_undo = false
+ if prev?
+ if prev_px?
+ new_prev_px = new_cursor_position n: prev, i: prev_i
+ if new_prev_px?
+ if new_prev_px.x isnt prev_px.x or new_prev_px.y isnt prev_px.y
+ need_undo = true
+ else
+ need_undo = true
+ else
+ console.log "this shouldn't happen, we remove spaces that don't locate"
+ if next? and not need_undo
+ if next_px?
+ new_next_px = new_cursor_position n: next, i: next_i
+ if new_next_px?
+ if new_next_px.x isnt next_px.x or new_next_px.y isnt next_px.y
+ need_undo = true
+ else
+ need_undo = true
+ #else
+ # console.log "removing space becase space after it is collapsed"
+ if need_undo
+ removed += fixer undo_arg
+ if removed > 0
+ return true
+ else
+ return false
# pass null at start/end of display:block
queue = (n, i) ->
next = n
constructor: (in_el, options) ->
@options = options ? {}
@in_el = in_el
- @tree = null
+ @tree = null # array of Nodes, all editable content
+ @tree_parent = null # @tree is this.children. .el might === @idoc.body
@matting = []
@init_1_called = false # when iframes have loaded
@outer_iframe # iframe to hold editor
@outer_idoc # "document" object for @outer_iframe
@wrap2 = null # scrollbar is on this
+ @wrap2_offset = null
+ @wrap2_height = null # including padding
@iframe = null # iframe to hold editable content
@idoc = null # "document" object for @iframe
@cursor = null
@cursor_el = null
@cursor_visible = false
+ @cursor_ideal_x = null
@poll_for_blur_timeout = null
- @wrap2_offset = null
- @iframe_height = null
opt_fragment = @options.fragment ? true
@parser_opts = {}
if opt_fragment
outer_bounds.h = 300
outer_iframe_style += "width: #{outer_bounds.w}px; height: #{outer_bounds.h}px;"
@outer_iframe.setAttribute 'style', outer_iframe_style
- css = outer_css w: outer_bounds.w, h: outer_bounds.h
+ css = @generate_outer_css w: outer_bounds.w, h: outer_bounds.h
outer_wrap.appendChild @outer_iframe
init_1: -> # @iframe has loaded (but not it's css)
@idoc = @iframe.contentDocument
@load_html @in_el.value
if @options.on_init?
@options.on_init()
+ generate_outer_css: (args) ->
+ w = args.w ? 300
+ h = args.h ? 300
+ inner_padding = args.inner_padding ? overlay_padding
+ frame_width = args.frame_width ? inner_padding
+ occupy = (left, top = left, right = left, bottom = top) ->
+ w -= left + right
+ h -= top + bottom
+ return Math.max(left, top, right, bottom)
+ ret = ''
+ ret += 'body {'
+ ret += 'margin: 0;'
+ ret += 'padding: 0;'
+ ret += 'color: black;'
+ ret += 'background: white;'
+ ret += '}'
+ ret += '#wrap1 {'
+ ret += "border: #{occupy 1}px solid black;"
+ ret += "padding: #{occupy frame_width}px;"
+ ret += '}'
+ ret += '#wrap2 {'
+ ret += "border: #{occupy 1}px solid black;"
+ @wrap2_height = h # including padding because padding scrolls
+ ret += "padding: #{occupy inner_padding}px;"
+ ret += "padding-right: #{inner_padding + occupy 0, 0, 15, 0}px;" # for scroll bar
+ ret += "width: #{w}px;"
+ ret += "height: #{h}px;"
+ ret += 'overflow-x: hidden;'
+ ret += 'overflow-y: scroll;'
+ ret += '}'
+ ret += '#wrap3 {'
+ ret += 'position: relative;'
+ ret += "width: #{w}px;"
+ ret += "min-height: #{h}px;"
+ ret += '}'
+ ret += 'iframe {'
+ ret += 'box-sizing: border-box;'
+ ret += 'margin: 0;'
+ ret += 'border: none;'
+ ret += 'padding: 0;'
+ ret += "width: #{w}px;"
+ #ret += "height: #{h}px;" # height auto-set when content set/changed
+ ret += '-ms-user-select: none;'
+ ret += '-webkit-user-select: none;'
+ ret += '-moz-user-select: none;'
+ ret += 'user-select: none;'
+ ret += '}'
+ ret += '#overlay {'
+ ret += 'position: absolute;'
+ ret += "left: -#{inner_padding}px;"
+ ret += "top: -#{inner_padding}px;"
+ ret += "right: -#{inner_padding}px;"
+ ret += "bottom: -#{inner_padding}px;"
+ ret += 'overflow: hidden;'
+ ret += '}'
+ ret += '.lightbox {'
+ ret += 'position: absolute;'
+ ret += 'background: rgba(100,100,100,0.2);'
+ ret += '}'
+ ret += '#cursor {'
+ ret += 'position: absolute;'
+ ret += 'width: 2px;'
+ ret += 'background: linear-gradient(0deg, rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1), rgba(255,255,255,1), rgba(0,0,0,1));'
+ ret += 'background-size: 200% 200%;'
+ ret += '-webkit-animation: blink 1s linear normal infinite;'
+ ret += 'animation: blink 1s linear normal infinite;'
+ ret += '}'
+ ret += '@-webkit-keyframes blink {'
+ ret += '0%{background-position:0% 0%}'
+ ret += '100%{background-position:0% -100%}'
+ ret += '}'
+ ret += '@keyframes blink { '
+ ret += '0%{background-position:0% 0%}'
+ ret += '100%{background-position:0% -100%}'
+ ret += '}'
+ ret += '.ann_box {'
+ ret += 'z-index: 5;'
+ ret += 'position: absolute;'
+ ret += 'border: 1px solid rgba(0,0,0,0.1);'
+ ret += 'outline: 1px solid rgba(255,255,255,0.1);' # in case there's a black background
+ ret += '}'
+ ret += '.ann_tag {'
+ ret += 'z-index: 10;'
+ ret += 'position: absolute;'
+ ret += 'font-size: 8px;'
+ ret += 'white-space: pre;'
+ ret += 'background: rgba(255,255,255,0.4);'
+ ret += '-ms-user-select: none;'
+ ret += '-webkit-user-select: none;'
+ ret += '-moz-user-select: none;'
+ ret += 'user-select: none;'
+ ret += '}'
+ return ret
overlay_event_to_inner_xy: (e) ->
unless @wrap2_offset?
@wrap2_offset = get_el_bounds @wrap2
return false
when KEY_UP
if @cursor?
- new_cursor = @cursor
- # go prev until we're higher on y axis
- while new_cursor.y >= @cursor.y
- new_cursor = find_prev_cursor_position @tree, new_cursor
- return false unless new_cursor?
- # done early if we're already left of old cursor position
- if new_cursor.x <= @cursor.x
- @move_cursor new_cursor
- return false
- target_y = new_cursor.y
- # search leftward, until we find the closest position
- # new_cursor is the prev-most position we've checked
- # prev_cursor is the older value, so it's not as prev as new_cursor
- while new_cursor.x > @cursor.x and new_cursor.y is target_y
- prev_cursor = new_cursor
- new_cursor = find_prev_cursor_position @tree, new_cursor
- break unless new_cursor?
- # move cursor to prev_cursor or new_cursor
+ new_cursor = find_up_cursor_position @tree, @cursor, @cursor_ideal_x
if new_cursor?
- if new_cursor.y is target_y
- # both valid, and on the same line, use closest
- if (@cursor.x - new_cursor.x) < (prev_cursor.x - @cursor.x)
- @move_cursor new_cursor
- else
- @move_cursor prev_cursor
- else
- # new_cursor on wrong line, use prev_cursor
- @move_cursor prev_cursor
- else
- # can't go any further prev, use prev_cursor
- @move_cursor prev_cursor
+ saved_ideal_x = @cursor_ideal_x
+ @move_cursor new_cursor
+ @cursor_ideal_x = saved_ideal_x
else
# move cursor to first position in document
new_cursor = first_cursor_position @tree
return false
when KEY_DOWN
if @cursor?
- new_cursor = @cursor
- # go next until we move on the y axis
- while new_cursor.y <= @cursor.y
- new_cursor = find_next_cursor_position @tree, new_cursor
- return false unless new_cursor?
- # done early if we're already right of old cursor position
- if new_cursor.x >= @cursor.x
- # this would be strange, but could happen due to runaround
- @move_cursor new_cursor
- return false
- target_y = new_cursor.y
- # search rightward, until we find the closest position
- # new_cursor is the next-most position we've checked
- # prev_cursor is the older value, so it's not as next as new_cursor
- while new_cursor.x < @cursor.x and new_cursor.y is target_y
- prev_cursor = new_cursor
- new_cursor = find_next_cursor_position @tree, new_cursor
- break unless new_cursor?
- # move cursor to prev_cursor or new_cursor
+ new_cursor = find_down_cursor_position @tree, @cursor, @cursor_ideal_x
if new_cursor?
- if new_cursor.y is target_y
- # both valid, and on the same line, use closest
- if (new_cursor.x - @cursor.x) < (@cursor.x - prev_cursor.x)
- @move_cursor new_cursor
- else
- @move_cursor prev_cursor
- else
- # new_cursor on wrong line, use prev_cursor
- @move_cursor prev_cursor
- else
- # can't go any further prev, use prev_cursor
- @move_cursor prev_cursor
+ saved_ideal_x = @cursor_ideal_x
+ @move_cursor new_cursor
+ @cursor_ideal_x = saved_ideal_x
else
# move cursor to first position in document
new_cursor = last_cursor_position @tree
@move_cursor new_cursor
return false
when KEY_END
- return false
- when KEY_BACKSPACE
- return false unless @cursor?
- return false unless @cursor.i > 0
- @cursor.n.text = @cursor.n.text.substr(0, @cursor.i - 1) + @cursor.n.text.substr(@cursor.i)
- @cursor.n.el.nodeValue = @cursor.n.text
- new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i - 1
+ new_cursor = last_cursor_position @tree
if new_cursor?
@move_cursor new_cursor
- else
- @kill_cursor()
- @changed()
+ return false
+ when KEY_BACKSPACE
+ @on_key_backspace e
return false
when KEY_DELETE
return false unless @cursor?
return false unless @cursor.i < @cursor.n.text.length
- @cursor.n.text = @cursor.n.text.substr(0, @cursor.i) + @cursor.n.text.substr(@cursor.i + 1)
- @cursor.n.el.nodeValue = @cursor.n.text
+ @remove_character @cursor.n, @cursor.i
+ @adjust_whitespace_style @cursor.n
+ @changed()
new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i
if new_cursor?
@move_cursor new_cursor
else
@kill_cursor()
- @changed()
return false
when KEY_ENTER
+ @on_key_enter e
return false
when KEY_ESCAPE
+ @kill_cursor()
return false
when KEY_HOME
+ new_cursor = first_cursor_position @tree
+ if new_cursor?
+ @move_cursor new_cursor
return false
when KEY_INSERT
return false
onkeypress: (e) ->
return if e.ctrlKey
return false if ignore_key_codes[e.keyCode]?
- # return false if control_key_codes[e.keyCode]? # handled in keydown
char = e.charCode ? e.keyCode
if char and @cursor?
char = String.fromCharCode char
- if @cursor.i is 0
- @cursor.n.text = char + @cursor.n.text
- else if @cursor.i is @cursor.n.text.length - 1
- @cursor.n.text += char
- else
- @cursor.n.text =
- @cursor.n.text.substr(0, @cursor.i) +
- char +
- @cursor.n.text.substr(@cursor.i)
- @cursor.n.el.nodeValue = @cursor.n.text
- new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i + 1
- unless new_cursor
- # probably pressed space, and browser isn't displaying it
- # FIXME insert instead, rip it out later if possible, etc.
- # for now, remove it
- @cursor.n.text =
- @cursor.n.text.substr(0, @cursor.i) +
- @cursor.n.text.substr(@cursor.i + 1)
- @cursor.n.el.nodeValue = @cursor.n.text
- return false
- @move_cursor new_cursor
+ @insert_character @cursor.n, @cursor.i, char
+ @adjust_whitespace_style @cursor.n
@changed()
+ new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i + 1
+ if new_cursor
+ @move_cursor new_cursor
+ else
+ console.log "ERROR: couldn't find cursor position after insert"
+ @kill_cursor()
return false
+ on_key_enter: (e) -> # enter key pressed
+ return unless @cursor_visible
+ cur_block = @cursor.n
+ loop
+ if cur_block.type is 'tag'
+ if is_display_block cur_block.el
+ break
+ return unless cur_block.parent?
+ cur_block = cur_block.parent
+ # find array to insert new element into
+ if cur_block.parent is @tree_parent # top-level
+ parent_el = @idoc.body
+ pc = @tree
+ else
+ parent_el = cur_block.parent.el
+ pc = cur_block.parent.children
+ # find index of current block in its parent
+ for n, i in pc
+ break if n is cur_block
+ i += 1 # we want to be after it
+ if i < pc.length
+ before = pc[i].el
+ else
+ before = null
+ # TODO if content after cursor
+ # TODO new block is empty
+ new_text = new peach_parser.Node 'text', text: ' '
+ new_node = new peach_parser.Node 'tag', name: 'p', parent: cur_block.parent, attrs: {style: 'white-space: pre-wrap'}, children: [new_text]
+ new_text.parent = new_node
+ new_text.el = domify @idoc, text: ' '
+ new_node.el = domify @idoc, p: style: 'white-space: pre-wrap', children: [new_text.el]
+ pc.splice i, 0, new_node
+ parent_el.insertBefore new_node.el, before
+ @changed()
+ new_cursor = new_cursor_position n: new_text, i: 0
+ throw 'bork bork' unless new_cursor?
+ @move_cursor new_cursor
+ # TODO move content past cursor into this new block
+ on_key_backspace: (e) ->
+ return false unless @cursor?
+ if @is_lone_space @cursor.n # false if it's not in a tag
+ if @cursor.i is 1
+ # don't delete the space, because then it would collapse
+ # instead leave a space after the cursor
+ new_cursor = new_cursor_position n: @cursor.n, i: 0
+ if new_cursor?
+ @move_cursor new_cursor
+ else
+ @kill_cursor()
+ else
+ parent = @cursor.n.parent
+ new_cursor = find_prev_cursor_position @tree, @cursor
+ if new_cursor?
+ if new_cursor.n is @cursor.n or new_cursor.n is parent
+ new_cursor = null
+ tag = @cursor.n.parent
+ if tag is @tree_parent
+ console.log "top-level text not supported" # FIXME
+ return false
+ for n, i in tag.parent.children
+ if n is tag
+ tag.parent.el.removeChild tag.el
+ tag.parent.children.splice i, 1
+ break
+ @changed()
+ if new_cursor?
+ # re-check, in case it moved or is invalid now
+ new_cursor = new_cursor_position n: new_cursor.n, i: new_cursor.i
+ if new_cursor?
+ @move_cursor new_cursor
+ return
+ new_cursor = first_cursor_position @tree
+ if new_cursor?
+ @move_cursor new_cursor
+ else
+ @kill_cursor
+ return
+ else if @cursor.i is 0
+ console.log 'unimplemented: backspace at start of non-empty tag'
+ # TODO if block, merge parent into prev
+ # TODO if inline, delete char from prev text node
+ return false
+ else
+ # TODO handle case of removing last char
+ # CONTINUE
+ if @is_only_char_in_tag @cursor.n
+ if is_display_block @cursor.n.parent.el
+ @cursor.n.el.textContent = @cursor.n.text = ' '
+ else
+ console.log "unimplemented: delete last char in inline" # FIXME
+ return
+ else
+ @remove_character @cursor.n, @cursor.i - 1
+ @adjust_whitespace_style @cursor.n
+ @changed()
+ new_cursor = new_cursor_position n: @cursor.n, i: @cursor.i - 1
+ if new_cursor?
+ @move_cursor new_cursor
+ else
+ @kill_cursor()
+ return
clear_dom: -> # remove all the editable content (and cursor, overlays, etc)
while @idoc.body.childNodes.length
@idoc.body.removeChild @idoc.body.childNodes[0]
return
load_html: (html) ->
@tree = peach_parser.parse html, @parser_opts
+ if !@tree[0]?.parent
+ @tree = peach_parser.parse '<p style="white-space: pre-wrap"> </p>', @parser_opts
+ @tree_parent = @tree[0]?.parent
+ @tree_parent.el = @idoc.body
@clear_dom()
- instantiate_tree @tree, @idoc.body
+ instantiate_tree @tree, @tree_parent.el
tree_dedup_space @tree
@changed()
changed: ->
@load_html @in_el.value
@adjust_iframe_height()
adjust_iframe_height: ->
+ s = @wrap2.scrollTop
+ # when the content gets shorter, the idoc's body tag will continue to
+ # report the old (too big) height in Chrome. The workaround is to
+ # shrink the iframe before the content height:
+ @iframe.style.height = "10px"
h = parseInt(@idoc.body.scrollHeight, 10)
- if @iframe_height isnt h
- @iframe_height = h
- s = @wrap2.scrollTop
- @iframe.style.height = "0"
- @iframe.style.height = "#{h}px"
- @wrap2.scrollTop = s
+ @iframe.style.height = "#{h}px"
+ @wrap2.scrollTop = s
+ # does this node have whitespace that would be collapsed by white-space: normal?
+ # note: this checks direct text children, and does _not_ recurse into child tags
+ # tag is a node with type:"tag"
+ has_collapsable_space: (tag) ->
+ for n in tag.children
+ if n.type is 'text'
+ for i in [0...n.text.length]
+ code = n.text.charCodeAt i
+ if code isnt 32 and is_space_code code
+ # tab, return
+ return true
+ # check for double spaces that don't surround insert location
+ continue if i is 0
+ if n.text.substr(i - 1, 2) is ' '
+ return true
+ if n.text.length > 0
+ if is_space_code n.text.charCodeAt 0
+ return true
+ if is_space_code n.text.charCodeAt n.text.length - 1
+ return true
+ # add/remove "white-space: pre[-wrap]" to/from style="" on tags with direct
+ # child text nodes with multiple spaces in a row, or spaces at the
+ # start/end.
+ #
+ # text inside child tags are not consulted. Child tags are expected to have
+ # this function applied to them when their content changes.
+ adjust_whitespace_style: (n) ->
+ if n.type is 'text'
+ n = n.parent
+ return unless n?.el?
+ # which css rule should be used to preserve spaces (should we need to)
+ style = @iframe.contentWindow.getComputedStyle n.el, null
+ ws = style.getPropertyValue 'white-space'
+ if ws_props[ws].space
+ preserve_rule = ws
+ else
+ preserve_rule = ws_props[ws].to_preserve
+ preserve_rule = "white-space: #{preserve_rule}"
+ if @has_collapsable_space n
+ # make sure preserve_rule exists
+ if n.el.style['white-space']
+ # FIXME check that it matches
+ return
+ if n.attrs[style]?
+ n.attrs.style += "; #{preserve_rule}"
+ else
+ n.attrs.style = preserve_rule
+ n.el.setAttribute 'style', n.attrs.style
+ else
+ # remove preserve_rule if it exists
+ return unless n.attrs.style?
+ # FIXME don't assume whitespace is just so
+ if n.attrs.style is "white-space: #{ws}"
+ delete n.attrs.style
+ n.el.removeAttribute 'style'
+ else
+ # FIXME find it in the middle and at the start
+ needle = "; white-space: #{ws}"
+ if needle is n.attrs.style.substr n.attrs.style.length - needle
+ n.attrs.style = n.attrs.style.substr 0, n.attrs.style.length - needle
+ n.el.setAttribute n.attrs.style
+ # true if n is text node with only one caracter, and the only child of a tag
+ is_only_char_in_tag: (n, i) ->
+ return false unless n.type is 'text'
+ return false unless n.text.length is 1
+ return false if n.parent is @tree_parent
+ return false unless n.parent.children.length is 1
+ return true
+ # true if n is text node with just a space in it, and the only child of a tag
+ is_lone_space: (n, i) ->
+ return false unless n.type is 'text'
+ return false unless n.text is ' '
+ return false if n.parent is @tree_parent
+ return false unless n.parent.children.length is 1
+ return true
+ # detect special case: typing before a space that's the only thing in a block/doc
+ # reason: enter key creates blocks with just a space in them
+ insert_should_replace: (n, i) ->
+ return false unless i is 0
+ return false unless n.text is ' '
+ return true if n.parent is @tree_parent
+ if n.parent.children.length is 1
+ if n.parent.children[0] is n
+ # n is only child
+ return true
+ return false
+ # after calling this, you MUST call changed() and adjust_whitespace_style()
+ insert_character: (n, i, char) ->
+ return if @cursor.n.parent is @tree_parent # FIXME implement text nodes at top level
+ parent = @cursor.n.parent
+ # insert the character
+ if @insert_should_replace n, i
+ n.text = char
+ else if i is 0
+ n.text = char + n.text
+ else if i is n.text.length
+ # replace the space
+ n.text += char
+ else
+ n.text =
+ n.text.substr(0, i) +
+ char +
+ n.text.substr(i)
+ n.el.nodeValue = n.text
+ # after calling this, you MUST call changed() and adjust_whitespace_style()
+ remove_character: (n, i) ->
+ n.text = n.text.substr(0, i) + n.text.substr(i + 1)
+ n.el.nodeValue = n.text
kill_cursor: -> # remove it, forget where it was
if @cursor_visible
@cursor_el.parentNode.removeChild @cursor_el
@cursor = null
@annotate null
move_cursor: (cursor) ->
+ @cursor_ideal_x = cursor.x
@cursor = cursor
unless @cursor_visible
@cursor_el = domify @outer_idoc, div: id: 'cursor'
@cursor_el.style.top = "#{cursor.y + overlay_padding + Math.round(height * .07)}px"
@cursor_el.style.height = "#{Math.round height * 0.82}px"
@annotate cursor.n
+ @scroll_into_view cursor.y, height
+ scroll_into_view: (y, h = 0) ->
+ closest = 30 # setting: smallest pixels from top/bottom of screet that's OK
+ y += overlay_padding # convert units from @idoc to @wrap2
+ # very top of document
+ if y <= closest
+ @wrap2.scrollTop = 0
+ return
+ # very bottom of document
+ if y + h >= @wrap2.scrollHeight - closest
+ @wrap2.scrollTop = @wrap2.scrollHeight - @wrap2_height
+ return
+ # The most scrolled up (lowest value for scrollTop) that would be OK
+ upmost = y + h + closest - @wrap2_height
+ upmost = Math.max(upmost, 0)
+ # the most scrolled down (highest value for scrollTop) that would be OK
+ downmost = y - closest
+ downmost = Math.min(downmost, @wrap2.scrollHeight - @wrap2_height)
+ if upmost > downmost # means h is too big to fit
+ # scroll so top is visible
+ @wrap2.scrollTop = downmost
+ return
+ if @wrap2.scrollTop < upmost
+ @wrap2.scrollTop = upmost
+ return
+ if @wrap2.scrollTop > downmost
+ @wrap2.scrollTop = downmost
+ return
+ return
annotate: (n) ->
while @matting.length > 0
@overlay.removeChild @matting[0]
return unless n?
prev_bounds = x: 0, y: 0, w: 0, h: 0
alpha = 0.1
- while n?.el?
+ while n?.el? and n isnt @tree_parent
if n.type is 'text'
n = n.parent
continue