X-Git-Url: https://jasonwoof.com/gitweb/?a=blobdiff_plain;ds=sidebyside;f=editor.js;fp=editor.js;h=16986f49e415c956cdca068483d6ec99cbb16e49;hb=447762cf08b157e951670256bd0d5b6fcf2e3cd1;hp=0000000000000000000000000000000000000000;hpb=a9b7b42e17754f9129f03be71c1509325401dc3c;p=peach-html5-editor.git diff --git a/editor.js b/editor.js new file mode 100644 index 0000000..16986f4 --- /dev/null +++ b/editor.js @@ -0,0 +1,2490 @@ +// Copyright 2015 Jason Woofenden +// This file implements an WYSIWYG editor in the browser (no contenteditable) +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +(function() { +var KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_END, KEY_ENTER, KEY_ESCAPE, KEY_HOME, KEY_INSERT, KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, KEY_TAB, KEY_UP, breathing_room, control_key_codes, enc_attr_regex, enc_text_regex, event_return, find_prev_cursor_position, find_up_cursor_position, get_el_bounds, ignore_key_codes, js_attr_regex, multi_sp_regex, no_text_elements, overlay_padding, text_range_bounds, valid_attr_regex, void_elements, ws_props, xy_to_cursor, slice = [].slice + +// SETTINGS +overlay_padding = 10 +breathing_room = 30 // minimum pixels above/below cursor (scrolling) + +function timeout (ms, cb) { + return setTimeout(cb, ms) +} + +function next_frame (cb) { + if (window.requestAnimationFrame != null) { + window.requestAnimationFrame(cb) + } else { + timeout(16, cb) + } +} + +function this_url_sans_path () { + var clip, ret + ret = "" + window.location.href + clip = ret.lastIndexOf('#') + if (clip > -1) { + ret = ret.substr(0, clip) + } + clip = ret.lastIndexOf('?') + if (clip > -1) { + ret = ret.substr(0, clip) + } + clip = ret.lastIndexOf('/') + if (clip > -1) { + 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, + to_collapse: 'nowrap' + }, + 'pre-wrap': { + space: true, + newline: true, + wrap: true, + to_collapse: 'normal' + } +} + +// 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" +js_attr_regex = new RegExp('^[oO][nN].') +// html5 spec says that only these characters are collapsable +multi_sp_regex = new RegExp('[\u0020\u0009\u000a\u000c\u000d][\u0020\u0009\u000a\u000c\u000d]') + +function str_has_ws_run (str) { + return multi_sp_regex.test(str) +} + +// text nodes don't have getBoundingClientRect(), so use selection api to find +// it. +get_el_bounds = window.bounds = function(el) { + var doc, range, rect, win, x_fix, y_fix + if (el.getBoundingClientRect != null) { + rect = el.getBoundingClientRect() + } else { + // text nodes don't have getBoundingClientRect(), so use range api + range = el.ownerDocument.createRange() + range.selectNodeContents(el) + rect = range.getBoundingClientRect() + } + doc = el.ownerDocument.documentElement + win = el.ownerDocument.defaultView + y_fix = win.pageYOffset - doc.clientTop + x_fix = win.pageXOffset - doc.clientLeft + return { + x: rect.left + x_fix, + y: rect.top + y_fix, + w: rect.width != null ? rect.width : rect.right - rect.left, + h: rect.height != null ? rect.height : rect.top - rect.bottom + } +} + +function is_display_block (el) { + if (el.currentStyle != null) { + return el.currentStyle.display === 'block' + } else { + return window.getComputedStyle(el, null).getPropertyValue('display') === 'block' + } +} + +// Pass return value from dom event handlers to this. +// If they return false, this will addinionally stop propagation and default. +function event_return (e, bool) { + if (bool === false) { + if (e.stopPropagation != null) { + e.stopPropagation() + } + if (e.preventDefault != null) { + e.preventDefault() + } + } + return bool +} + +// Warning: currently assumes you're asking about a single character +// Note: chromium returns multiple bounding rects for a space at a line-break +// Note: chromium's getBoundingClientRect() is broken (when zero-area client rects) +// Note: sometimes returns null (eg for whitespace that is not visible) +text_range_bounds = function(el, start, end) { + var doc, range, rect, rects, win, x_fix, y_fix + range = document.createRange() + range.setStart(el, start) + range.setEnd(el, end) + rects = range.getClientRects() + if (rects.length > 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 + win = el.ownerDocument.defaultView + y_fix = win.pageYOffset - doc.clientTop + x_fix = win.pageXOffset - doc.clientLeft + return { + x: rect.left + x_fix, + y: rect.top + y_fix, + w: rect.width != null ? rect.width : rect.right - rect.left, + h: rect.height != null ? rect.height : rect.top - rect.bottom, + rects: rects, + bounding: range.getBoundingClientRect() + } +} + +function CursorPosition(args) { + this.n = args.n != null ? args.n : null + this.i = args.i != null ? args.i : null + if (args.x != null) { + this.x = args.x + this.y = args.y + this.h = args.h + } else { + this.set_xyh() + } +} + +CursorPosition.prototype.set_xyh = function() { + var range, ret + range = document.createRange() + if (this.n.text.length === 0) { + ret = text_range_bounds(this.n.el, 0, 0) + } else if (this.i === this.n.text.length) { + ret = text_range_bounds(this.n.el, this.i - 1, this.i) + if (ret != null) { + ret.x += ret.w + } + } else { + ret = text_range_bounds(this.n.el, this.i, this.i + 1) + } + if (ret != null) { + this.x = ret.x + this.y = ret.y + this.h = ret.h + } else { + this.x = null + this.y = null + this.h = null + } + return ret +} + +function new_cursor_position (args) { + var ret + ret = new CursorPosition(args) + if (ret.x != null) { + return ret + } + return null +} + +// encode text so it can be safely placed inside an html attribute +enc_attr_regex = new RegExp('(&)|(")|(\u00A0)', 'g') +function enc_attr (txt) { + return txt.replace(enc_attr_regex, function(match, amp, quote) { + if (amp) { + return '&' + } + if (quote) { + return '"' + } + return ' ' + }) +} +enc_text_regex = new RegExp('(&)|(<)|(\u00A0)', 'g') +function enc_text (txt) { + return txt.replace(enc_text_regex, function(match, amp, lt) { + if (amp) { + return '&' + } + if (lt) { + return '<' + } + return ' ' + }) +} + +void_elements = { + area: true, + base: true, + br: true, + col: true, + embed: true, + hr: true, + img: true, + input: true, + keygen: true, + link: true, + meta: true, + param: true, + source: true, + track: true, + wbr: true +} +// TODO make these always pretty-print (on the inside) like blocks +// TODO careful though: whitespace might get pushed to parent, which might be rendered +no_text_elements = { // these elements never contain text + select: true, + table: true, + tr: true, + thead: true, + tbody: true, + ul: true, + ol: true +} + +function domify (doc, hash) { + var attrs, el, i, k, tag, v + for (tag in hash) { + attrs = hash[tag] + if (tag === 'text') { + return document.createTextNode(attrs) + } + el = document.createElement(tag) + for (k in attrs) { + v = attrs[k] + if (k === 'children') { + for (i = 0; i < v.length; i++) { + el.appendChild(v[i]) + } + } else { + el.setAttribute(k, v) + } + } + } + return el +} + +ignore_key_codes = { + '18': true, // alt + '20': true, // capslock + '17': true, // ctrl + '144': true, // numlock + '16': true, // shift + '91': true // windows "start" key +} +// key codes: (valid on keydown, not keypress) +KEY_LEFT = 37 +KEY_UP = 38 +KEY_RIGHT = 39 +KEY_DOWN = 40 +KEY_BACKSPACE = 8 // <-- +KEY_DELETE = 46 // --> +KEY_END = 35 +KEY_ENTER = 13 +KEY_ESCAPE = 27 +KEY_HOME = 36 +KEY_INSERT = 45 +KEY_PAGE_UP = 33 +KEY_PAGE_DOWN = 34 +KEY_TAB = 9 +control_key_codes = { // we react to these, but they aren't typing + '37': KEY_LEFT, + '38': KEY_UP, + '39': KEY_RIGHT, + '40': KEY_DOWN, + '35': KEY_END, + '8': KEY_BACKSPACE, + '46': KEY_DELETE, + '13': KEY_ENTER, + '27': KEY_ESCAPE, + '36': KEY_HOME, + '45': KEY_INSERT, + '33': KEY_PAGE_UP, + '34': KEY_PAGE_DOWN, + '9': KEY_TAB +} + +function instantiate_tree (tree, parent) { + var c, i, k, remove, results, v + remove = [] + for (i = 0; i < tree.length; ++i) { + c = tree[i] + switch (c.type) { + case 'text': + c.el = parent.ownerDocument.createTextNode(c.text) + parent.appendChild(c.el) + break + case 'tag': + if (c.name === 'script' || c.name === 'object' || c.name === 'iframe' || c.name === 'link') { + // TODO put placeholders instead + remove.unshift(i) + continue + } + // TODO create in correct namespace + c.el = parent.ownerDocument.createElement(c.name) + ref1 = c.attrs + for (k in ref1) { + v = ref1[k] + // FIXME if attr_whitelist[k]? + if (valid_attr_regex.test(k)) { + if (!js_attr_regex.test(k)) { + c.el.setAttribute(k, v) + } + } + } + parent.appendChild(c.el) + if (c.children.length) { + instantiate_tree(c.children, c.el) + } + } + } + results = [] + for (i = 0; i < remove.length; i++) { + // FIXME this deletes the wrong node when siblings are removed + index = remove[i] + results.push(tree.splice(index, 1)) + } + return results +} + +function traverse_tree (tree, cb) { + var c, done, i + done = false + for (i = 0; i < tree.length; i++) { + c = tree[i] + done = cb(c) + if (done) { + return done + } + if (c.children.length) { + done = traverse_tree(c.children, cb) + if (done) { + return done + } + } + } + return done +} + +function first_cursor_position (tree) { + var found + found = null + traverse_tree(tree, function(node, state) { + var cursor + if (node.type === 'text') { + cursor = new_cursor_position({n: node, i: 0}) + if (cursor != null) { + found = cursor + return true // done traversing + } + } + return false // not done traversing + }) + return found // maybe null +} + +// this will fail when text has non-locatable cursor positions (eg collapsed whitespace) +function find_next_cursor_position (tree, cursor) { + var found, new_cursor, state_before + if (cursor.n.type === 'text' && cursor.n.text.length > cursor.i) { + new_cursor = new_cursor_position({n: cursor.n, i: cursor.i + 1}) + if (new_cursor != null) { + return new_cursor + } + } + state_before = true + found = null + traverse_tree(tree, function(node, state) { + if (node.type === 'text' && state_before === false) { + new_cursor = new_cursor_position({n: node, i: 0}) + if (new_cursor != null) { + found = new_cursor + return true // done traversing + } + } + if (node === cursor.n) { + state_before = false + } + return false // not done traversing + }) + if (found != null) { + return found + } + return null +} + +function last_cursor_position (tree) { + var found + found = null + traverse_tree(tree, function(node) { + var cursor + if (node.type === 'text') { + cursor = new_cursor_position({n: node, i: node.text.length}) + if (cursor != null) { + found = cursor + } + } + return false // not done traversing + }) + return found // maybe null +} + +// this will fail when text has non-locatable cursor positions (eg collapsed whitespace) +function find_prev_cursor_position (tree, cursor) { + var found, found_prev, new_cursor + if (cursor.n.type === 'text' && cursor.i > 0) { + new_cursor = new_cursor_position({n: cursor.n, i: cursor.i - 1}) + if (new_cursor != null) { + return new_cursor + } + } + found_prev = null + found = null + traverse_tree(tree, function(node) { + if (node === cursor.n) { + found = found_prev // maybe null + return true // done traversing + } + if (node.type === 'text') { + new_cursor = new_cursor_position({n: node, i: node.text.length}) + if (new_cursor != null) { + found_prev = new_cursor + } + } + return false // not done traversing + }) + return found // maybe null +} + +function find_up_cursor_position (tree, cursor, ideal_x) { + var new_cursor, prev_cursor, target_y + 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) + if (new_cursor == null) { + return null + } + } + // 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 && new_cursor.y === target_y) { + prev_cursor = new_cursor + new_cursor = find_prev_cursor_position(tree, new_cursor) + if (new_cursor == null) { + break + } + } + // move cursor to prev_cursor or new_cursor + if (new_cursor != null) { + if (new_cursor.y === 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 + } +} + +function find_down_cursor_position (tree, cursor, ideal_x) { + var new_cursor, prev_cursor, target_y + 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) + if (new_cursor == null) { + return null + } + } + // 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 && new_cursor.y === target_y) { + prev_cursor = new_cursor + new_cursor = find_next_cursor_position(tree, new_cursor) + if (new_cursor == null) { + break + } + } + if (new_cursor != null) { + if (new_cursor.y === 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 + } +} + +function xy_to_cursor (tree, xy) { + var after, before, bounds, cur, guess_i, i, n, ret + for (i = 0; i < tree.length; i++) { + n = tree[i] + if (n.type === 'tag' || n.type === 'text') { + bounds = get_el_bounds(n.el) + if (xy.x < bounds.x) { + continue + } + if (xy.x > bounds.x + bounds.w) { + continue + } + if (xy.y < bounds.y) { + continue + } + if (xy.y > bounds.y + bounds.h) { + continue + } + if (n.children.length) { + ret = xy_to_cursor(n.children, xy) + if (ret != null) { + return ret + } + } + if (n.type === 'text') { + // click is within bounding box that contains all text. + if (n.text.length === 0) { + ret = new_cursor_position({n: n, i: 0}) + if (ret != null) { + return ret + } + continue + } + before = new_cursor_position({n: n, i: 0}) + if (before == null) { + continue + } + after = new_cursor_position({n: n, i: n.text.length}) + if (after == null) { + continue + } + if (xy.y < before.y + before.h && xy.x < before.x) { + // console.log 'before first char on first line' + continue + } + if (xy.y > after.y && xy.x > after.x) { + // console.log 'after last char on last line' + continue + } + if (xy.y < before.y) { + console.log("Warning: click in text bounding box but above first line") + continue // above first line (runaround?) + } + if (xy.y > after.y + after.h) { + console.log("Warning: click in text bounding box but below last line", xy.y, after.y, after.h) + continue // below last line (shouldn't happen?) + } + while (after.i - before.i > 1) { + guess_i = Math.round((before.i + after.i) / 2) + cur = new_cursor_position({n: n, i: guess_i}) + if (cur == null) { + console.log("error: failed to find cursor pixel location for", n, guess_i) + before = null + break + } + if (xy.y < cur.y || (xy.y <= cur.y + cur.h && xy.x < cur.x)) { + after = cur + } else { + before = cur + } + } + if (before == null) { // signals failure to find a cursor position + continue + } + // which one is closest? + if (Math.abs(before.x - xy.x) < Math.abs(after.x - xy.x)) { + return before + } else { + return after + } + } + } + } + return null +} + +// browsers collapse these (html5 spec calls these "space characters") +function is_space_code (char_code) { + switch (char_code) { + case 9: + case 10: + case 12: + case 13: + case 32: + return true + } + return false +} +function is_space (chr) { + return is_space_code(chr.charCodeAt(0)) +} + +function tree_remove_empty_text_nodes (tree) { + var c, empties, i, j, n + empties = [] + traverse_tree(tree, function(n) { + if (n.type === 'text') { + if (n.text.length === 0) { + empties.unshift(n) + } + } + return false // not done traversing + }) + for (i = 0; i < empties.length; i++) { + n = empties[i] + // don't completely empty the tree + if (tree.length === 1) { + if (tree[0].type === 'text') { + console.log("oop, leaving a blank node because it's the only thing") + return + } + } + n.el.parentNode.removeChild(n.el) + ref = n.parent.children + for (j = 0; j < ref.length; ++j) { + c = ref[j] + if (c === n) { + n.parent.children.splice(j, 1) + break + } + } + } +} + +function PeachHTML5Editor (in_el, options) { + // Options: (all optional) + // editor_id: "id" attribute for outer-most element created by/for editor + // css_file: filename of a css file to style editable content + // on_init: callback for when the editable content is in place + var css, opt_fragment, outer_bounds, outer_iframe_style, outer_wrap + this.options = options != null ? options : {} + this.in_el = in_el + this.tree = null // array of Nodes, all editable content + this.tree_parent = null // this.tree is this.children. .el might === this.idoc.body + this.matting = [] + this.init_1_called = false // when iframes have loaded + this.outer_iframe // iframe to hold editor + this.outer_idoc // "document" object for this.outer_iframe + this.wrap2 = null // scrollbar is on this + this.wrap2_offset = null + this.wrap2_height = null // including padding + this.iframe = null // iframe to hold editable content + this.idoc = null // "document" object for this.iframe + this.cursor = null + this.cursor_el = null + this.cursor_visible = false + this.cursor_ideal_x = null + this.poll_for_blur_timeout = null + opt_fragment = this.options.fragment != null ? this.options.fragment : true + this.parser_opts = {} + if (opt_fragment) { + this.parser_opts.fragment = 'body' + } + this.outer_iframe = domify(document, {iframe: {}}) + outer_iframe_style = 'border: none !important; margin: 0 !important; padding: 0 !important; height: 100% !important; width: 100% !important;' + if (this.options.editor_id != null) { + this.outer_iframe.setAttribute('id', this.options.editor_id) + } + this.outer_iframe.onload = (function(_this) { + return function() { + var icss + _this.outer_idoc = _this.outer_iframe.contentDocument + icss = domify(_this.outer_idoc, { style: { children: [ + domify(_this.outer_idoc, {text: css}) + ]}}) + _this.outer_idoc.head.appendChild(icss) + _this.iframe = domify(_this.outer_idoc, {iframe: {sandbox: 'allow-same-origin allow-scripts'}}) + _this.iframe.onload = function() { + return _this.init_1() + } + timeout(200, function() { // firefox never fires this onload + if (!_this.init_1_called) { + return _this.init_1() + } + }) + _this.outer_idoc.body.appendChild( + domify(_this.outer_idoc, {div: {id: 'wrap1', children: [ + domify(_this.outer_idoc, {div: { + style: "position: absolute; top: 0; left: 1px; font-size: 10px", + children: [domify(_this.outer_idoc, {text: "Peach HTML5 Editor"})] + }}), + _this.wrap2 = domify(_this.outer_idoc, {div: {id: 'wrap2', children: [ + domify(_this.outer_idoc, {div: {id: 'wrap3', children: [ + _this.iframe, + _this.overlay = domify(_this.outer_idoc, { div: { id: 'overlay' }}) + ]}}) + ]}}) + ]}}) + ) + } + })(this) + outer_wrap = domify(document, {div: {"class": 'peach_html5_editor' }}) + this.in_el.parentNode.appendChild(outer_wrap) + outer_bounds = get_el_bounds(outer_wrap) + if (outer_bounds.w < 300) { + outer_bounds.w = 300 + } + if (outer_bounds.h < 300) { + outer_bounds.h = 300 + } + outer_iframe_style += "width: " + outer_bounds.w + "px; height: " + outer_bounds.h + "px;" + this.outer_iframe.setAttribute('style', outer_iframe_style) + css = this.generate_outer_css({w: outer_bounds.w, h: outer_bounds.h}) + outer_wrap.appendChild(this.outer_iframe) +} +PeachHTML5Editor.prototype.init_1 = function() { // this.iframe has loaded (but not it's css) + var istyle + this.idoc = this.iframe.contentDocument + this.init_1_called = true + // chromium doesn't resolve relative urls as though they were at the same domain + // so add a tag + this.idoc.head.appendChild(domify(this.idoc, {base: {href: this_url_sans_path()}})) + // don't let @iframe have scrollbars + this.idoc.head.appendChild(domify(this.idoc, {style: {children: [ + domify(this.idoc, {text: "body { overflow: hidden; }"}) + ]}})) + if (this.options.css_file) { + istyle = domify(this.idoc, {link: {rel: 'stylesheet', href: this.options.css_file}}) + istyle.onload = (function(_this) { + return function() { + return _this.init_2() + } + })(this) + this.idoc.head.appendChild(istyle) + } else { + this.init_2() + } +} +PeachHTML5Editor.prototype.init_2 = function() { // this.iframe and it's css file(s) are ready + this.overlay.onclick = (function(_this) { + return function(e) { + _this.have_focus() + return event_return(e, _this.onclick(e)) + } + })(this) + this.overlay.ondoubleclick = (function(_this) { + return function(e) { + _this.have_focus() + return event_return(e, _this.ondoubleclick(e)) + } + })(this) + this.outer_idoc.body.onkeyup = (function(_this) { + return function(e) { + _this.have_focus() + return event_return(e, _this.onkeyup(e)) + } + })(this) + this.outer_idoc.body.onkeydown = (function(_this) { + return function(e) { + _this.have_focus() + return event_return(e, _this.onkeydown(e)) + } + })(this) + this.outer_idoc.body.onkeypress = (function(_this) { + return function(e) { + _this.have_focus() + return event_return(e, _this.onkeypress(e)) + } + })(this) + this.load_html(this.in_el.value) + if (this.options.on_init != null) { + return this.options.on_init() + } +} +PeachHTML5Editor.prototype.generate_outer_css = function(args) { + var frame_width, h, inner_padding, occupy, ret, w + w = args.w != null ? args.w : 300 + h = args.h != null ? args.h : 300 + inner_padding = args.inner_padding != null ? args.inner_padding : overlay_padding + frame_width = args.frame_width != null ? args.frame_width : inner_padding + occupy = function(left, top, right, bottom) { + if (top == null) { + top = left + } + if (right == null) { + right = left + } + if (bottom == null) { + 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;" + this.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;" + 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 +} +PeachHTML5Editor.prototype.overlay_event_to_inner_xy = function(e) { + var x, y + if (this.wrap2_offset == null) { + this.wrap2_offset = get_el_bounds(this.wrap2) + } + x = e.pageX - overlay_padding + y = e.pageY - overlay_padding + this.wrap2.scrollTop + return { + x: x - this.wrap2_offset.x, + y: y - this.wrap2_offset.y + } +} +PeachHTML5Editor.prototype.onclick = function(e) { + var new_cursor, xy + xy = this.overlay_event_to_inner_xy(e) + new_cursor = xy_to_cursor(this.tree, xy) + if (new_cursor != null) { + this.move_cursor(new_cursor) + } else { + this.kill_cursor() + } + return false +} +PeachHTML5Editor.prototype.ondoubleclick = function(e) { + return false +} +PeachHTML5Editor.prototype.onkeyup = function(e) { + if (e.ctrlKey) { + return + } + if (ignore_key_codes[e.keyCode] != null) { + return false + } + //return false if control_key_codes[e.keyCode] != null +} +PeachHTML5Editor.prototype.onkeydown = function(e) { + var new_cursor, saved_ideal_x + if (e.ctrlKey) { + return + } + if (ignore_key_codes[e.keyCode] != null) { + return false + } + //return false if control_key_codes[e.keyCode] != null + switch (e.keyCode) { + case KEY_LEFT: + if (this.cursor != null) { + new_cursor = find_prev_cursor_position(this.tree, this.cursor) + } else { + new_cursor = first_cursor_position(this.tree) + } + if (new_cursor != null) { + this.move_cursor(new_cursor) + } + return false + case KEY_RIGHT: + if (this.cursor != null) { + new_cursor = find_next_cursor_position(this.tree, this.cursor) + } else { + new_cursor = last_cursor_position(this.tree) + } + if (new_cursor != null) { + this.move_cursor(new_cursor) + } + return false + case KEY_UP: + if (this.cursor != null) { + new_cursor = find_up_cursor_position(this.tree, this.cursor, this.cursor_ideal_x) + if (new_cursor != null) { + saved_ideal_x = this.cursor_ideal_x + this.move_cursor(new_cursor) + this.cursor_ideal_x = saved_ideal_x + } + } else { + // move cursor to first position in document + new_cursor = first_cursor_position(this.tree) + if (new_cursor != null) { + this.move_cursor(new_cursor) + } + } + return false + case KEY_DOWN: + if (this.cursor != null) { + new_cursor = find_down_cursor_position(this.tree, this.cursor, this.cursor_ideal_x) + if (new_cursor != null) { + saved_ideal_x = this.cursor_ideal_x + this.move_cursor(new_cursor) + this.cursor_ideal_x = saved_ideal_x + } + } else { + // move cursor to first position in document + new_cursor = last_cursor_position(this.tree) + if (new_cursor != null) { + this.move_cursor(new_cursor) + } + } + return false + case KEY_END: + new_cursor = last_cursor_position(this.tree) + if (new_cursor != null) { + this.move_cursor(new_cursor) + } + return false + case KEY_BACKSPACE: + this.on_key_backspace(e) + return false + case KEY_DELETE: + if (this.cursor == null) { + return false + } + new_cursor = find_next_cursor_position(this.tree, {n: this.cursor.n, i: this.cursor.i}) + // try moving cursor right and then running backspace code + // TODO replace this hack with a real implementation + if (new_cursor != null) { + // try to detect common case where cursor goes inside an block, + // but doesn't pass a character (and advance one more in that case) + if (new_cursor.n !== this.cursor.n && new_cursor.i === 0) { + if (new_cursor.n.type === 'text' && new_cursor.n.text.length > 0) { + if (new_cursor.n.parent != null) { + if (!this.is_display_block(new_cursor.n.parent)) { + // FIXME should test run sibling + new_cursor = new_cursor_position({n: new_cursor.n, i: new_cursor.i + 1}) + } + } + } + } + } + if (new_cursor != null) { + if (new_cursor.n !== this.cursor.n || new_cursor.i !== this.cursor.i) { + this.move_cursor(new_cursor) + this.on_key_backspace(e) + } + } + return false + case KEY_ENTER: + this.on_key_enter(e) + return false + case KEY_ESCAPE: + this.kill_cursor() + return false + case KEY_HOME: + new_cursor = first_cursor_position(this.tree) + if (new_cursor != null) { + this.move_cursor(new_cursor) + } + return false + case KEY_INSERT: + return false + case KEY_PAGE_UP: + this.on_page_up_key(e) + return false + case KEY_PAGE_DOWN: + this.on_page_down_key(e) + return false + case KEY_TAB: + return false + } +} +PeachHTML5Editor.prototype.onkeypress = function(e) { + var char, new_cursor + if (e.ctrlKey) { + return + } + if (ignore_key_codes[e.keyCode] != null) { + return false + } + char = e.charCode != null ? e.charCode : e.keyCode + if (char && (this.cursor != null)) { + char = String.fromCharCode(char) + this.insert_character(this.cursor.n, this.cursor.i, char) + this.text_cleanup(this.cursor.n) + this.changed() + new_cursor = new_cursor_position({n: this.cursor.n, i: this.cursor.i + 1}) + if (new_cursor) { + this.move_cursor(new_cursor) + } else { + console.log("ERROR: couldn't find cursor position after insert") + this.kill_cursor() + } + } + return false +} +PeachHTML5Editor.prototype.on_key_enter = function(e) { // enter key pressed + var before, cur_block, i, n, new_cursor, new_node, new_text, parent_el, pc + if (!this.cursor_visible) { + return + } + cur_block = this.cursor.n + while (true) { + if (cur_block.type === 'tag') { + if (is_display_block(cur_block.el)) { + break + } + } + if (cur_block.parent == null) { + return + } + cur_block = cur_block.parent + } + // find array to insert new element into + if (cur_block.parent === this.tree_parent) { + parent_el = this.idoc.body + pc = this.tree + } else { + parent_el = cur_block.parent.el + pc = cur_block.parent.children + } + for (i = 0; i < pc.length; ++i) { + n = pc[i] + if (n === cur_block) { + break + } + } + 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(this.idoc, {text: ' '}) + new_node.el = domify(this.idoc, {p: {style: 'white-space: pre-wrap', children: [new_text.el]}}) + pc.splice(i, 0, new_node) + parent_el.insertBefore(new_node.el, before) + this.changed() + new_cursor = new_cursor_position({ + n: new_text, + i: 0 + }) + if (new_cursor == null) { + throw 'bork bork' + } + this.move_cursor(new_cursor) + // TODO move content past cursor into this new block + return false +} +// unlike the global function, this takes a Node, not an element +PeachHTML5Editor.prototype.is_display_block = function(n) { + // TODO stop calling global function, merge it into here, use iframe's window object + if (n.type !== 'tag') { + return false + } + return is_display_block(n.el) +} +PeachHTML5Editor.prototype.find_block_parent = function(n) { + while (true) { + n = n.parent + if (n == null) { + return null + } + if (this.is_display_block(n)) { + return n + } + if (n === this.tree_parent) { + return n + } + } + return null +} +// return a flat array of nodes (text,
, and later also inline-block) +// that are flowing/wrapping together. n can be the containing block, or any +// element inside it. +PeachHTML5Editor.prototype.get_text_run = function(n) { + var block, ret + ret = [] + if (this.is_display_block(n)) { + block = n + } else { + block = this.find_block_parent(n) + if (block == null) { + return ret + } + } + traverse_tree(block.children, (function(_this) { return function(n) { + var disp + if (n.type === 'text') { + ret.push(n) + } else if (n.type === 'tag') { + if (n.name === 'br') { + ret.push(n) + } else { + disp = _this.computed_style(n) + if (disp === 'inline-block') { + ret.push(n) + } + } + } + return false // not done traversing + }})(this)) + return ret +} +PeachHTML5Editor.prototype.node_is_decendant = function(young, old) { + while (young != null && young !== this.tree_parent) { + if (young === old) { + return true + } + young = young.parent + } + return false +} +// helper for on_key_backspace +PeachHTML5Editor.prototype._merge_left = function(state) { + var pi, prev + // the node prev to n was not prev to it a moment ago, merge with it if reasonable + pi = state.n.parent.children.indexOf(state.n) + if (pi > 0) { + prev = state.n.parent.children[pi - 1] + if (prev.type === 'text') { + state.i = prev.text.length + prev.text = prev.el.textContent = prev.text + state.n.text + this.remove_node(state.n) + state.n = prev + state.changed = true + state.moved_cursor = true + } + } + // else // TODO merge possible consecutive matching inline tags at @cursor + return state +} +// helper for on_key_backspace +// remove n from the dom, also remove its inline parents that are emptied by removing n +PeachHTML5Editor.prototype._backspace_node_helper = function(n, run, run_i) { + var block + if (run == null) { + run = this.get_text_run(n) + } + if (run_i == null) { + run_i = run.indexOf(n) + } + block = this.find_block_parent(n) + this.remove_node(n) + n = n.parent + while (n != null && n !== block) { + // bail if the previous node in this run is also inside the same parent + if (run_i > 0) { + if (this.node_is_decendant(run[run_i - 1], n)) { + break + } + } + // bail if the next node in this run is also inside the same parent + if (run_i + 1 < run.length) { + if (this.node_is_decendant(run[run_i + 1], n)) { + break + } + } + // move any sibling nodes to parent. These nodes are not in the text run + while (n.children.length > 0) { + this.move_node(n.children[0], n.parent, n) + } + // remove (now completely empty) inline parent + this.remove_node(n) + // proceed to outer parent + n = n.parent + } +} +PeachHTML5Editor.prototype.on_key_backspace = function(e) { + var block, changed, merge_state, n, ncb, need_text_cleanup, new_cursor, pcb, post, pre, prev, prev_cursor, run, run_i + if (this.cursor == null) { + return + } + new_cursor = null + run = null + changed = true + if (this.cursor.i === 0) { // cursor is at start of text node + if (run == null) { + run = this.get_text_run(this.cursor.n) + } + run_i = run.indexOf(this.cursor.n) + if (run_i === 0) { // if at start of text run + block = this.find_block_parent(this.cursor.n) + prev_cursor = find_prev_cursor_position(this.tree, {n: this.cursor.n, i: 0}) + if (prev_cursor === null) { // if in first text run of document + // do nothing (there's nothing text-like to the left of the cursor) + return + } + // else merge with prev/outer text run + pcb = this.find_block_parent(prev_cursor.n) + while (block.children.length > 0) { + this.move_node(block.children[0], pcb) + } + this.remove_node(block) + // merge possible consecutive text nodes at @cursor + merge_state = {n: this.cursor.n} + this._merge_left(merge_state) + this.text_cleanup(merge_state.n) + new_cursor = new_cursor_position({n: merge_state.n, i: merge_state.i}) + } else { // at start of text node, but not start of text run + prev = run[run_i - 1] + if (prev.type === 'text') { // if previous in text run is text + if (prev.text.length === 1) { // if emptying prev (in text run) + this._backspace_node_helper(prev, run, run_i) + merge_state = {n: this.cursor.n, i: this.cursor.i} + this._merge_left(merge_state) + this.text_cleanup(merge_state.n) + new_cursor = new_cursor_position({n: merge_state.n, i: merge_state.i}) + } else { // prev in run is text with muliple chars + // delete last character in prev + prev.text = prev.text.substr(0, prev.text.length - 1) + prev.el.textContent = prev.text + this.text_cleanup(this.cursor.n) + new_cursor = new_cursor_position({n: this.cursor.n, i: this.cursor.i}) + } + } else if (prev.name === 'br' || prev.name === 'hr') { + this._backspace_node_helper(prev, run, run_i) + merge_state = {n: this.cursor.n, i: this.cursor.i} + this._merge_left(merge_state) + this.text_cleanup(merge_state.n) + new_cursor = new_cursor_position({n: merge_state.n, i: merge_state.i}) + } + // FIXME implement this: + // else // if prev (in run) is inline-block + // if that inline-block has text in it + // delete last char in prev inlineblock + // if that empties it + // delete it + // merge left + // else + // move cursor inside + // else + // delete prev (inline) block + // merge left + // auto-delete this @cursor.parent(s) if this empties them + } + } else { // cursor is not at start of text node + if (run == null) { + run = this.get_text_run(this.cursor.n) + } + if (this.cursor.n.text.length === 1) { // if emptying text node + if (run.length === 1) { // if emptying text run (of text/br/hr/inline-block) + // remove inline-parents of @cursor.n + block = this.find_block_parent(this.cursor.n) + changed = false + n = this.cursor.n.parent + // note: this doesn't use _backspace_node_helper because: + // 1. we don't want to delete the target node (we're replacing it's contents) + // 2. we want to track whether anything was removed + // 3. we know already know there's no other text from this run anywhere + while (n && n !== block) { + changed = true + while (n.children.length > 0) { + this.move_node(n.children[0], n.parent, n) + } + this.remove_node(n) + n = n.parent + } + // replace @cursor.n with a single (preserved) space + if (this.cursor.n.text !== ' ') { + changed = true + this.cursor.n.text = this.cursor.n.el.textContent = ' ' + } + if (changed) { + this.text_cleanup(this.cursor.n) + } + // place the cursor to the left of that space + new_cursor = new_cursor_position({n: this.cursor.n, i: 0}) + } else { // emptying a text node (but not a whole text run) + // figure out where cursor should land + block = this.find_block_parent(this.cursor.n) + new_cursor = find_prev_cursor_position(this.tree, {n: this.cursor.n, i: 0}) + ncb = this.find_block_parent(new_cursor.n) + if (ncb !== block) { + new_cursor = find_next_cursor_position(this.tree, {n: this.cursor.n, i: 1}) + } + // delete text node and cleanup emptied parents + run_i = run.indexOf(this.cursor.n) + this._backspace_node_helper(this.cursor.n, run, run_i) + // see if new adjacent siblings should merge + // TODO make smarter + if (run_i > 0 && run_i + 1 < run.length) { + if (run[run_i - 1].type === 'text' && run[run_i + 1].type === 'text') { + merge_state = {n: run[run_i + 1]} + this._merge_left(merge_state) + if (merge_state.moved_cursor) { + new_cursor = merge_state + } + } + } + // update whitespace preservation + this.text_cleanup(block) + // update cursor x/y in case things moved around + if (new_cursor != null) { + if (new_cursor.n.el.parentNode) { // still in dom after cleanup + new_cursor = new_cursor_position({n: new_cursor.n, i: new_cursor.i}) + } else { + new_cursor = null + } + } + } + } else { // there's a char left of cursor that we can delete without emptying anything + // delete character + need_text_cleanup = true + if (this.cursor.i > 1 && this.cursor.i < this.cursor.n.text.length) { + pre = this.cursor.n.text.substr(this.cursor.i - 2, 3) + post = pre.charAt(0) + pre.charAt(2) + if (str_has_ws_run(pre) === str_has_ws_run(post)) { + need_text_cleanup = false + } + } + this.remove_character(this.cursor.n, this.cursor.i - 1) + // call text_cleanup if whe created/removed a whitespace run + if (need_text_cleanup) { + this.text_cleanup(this.cursor.n) + } + new_cursor = new_cursor_position({n: this.cursor.n, i: this.cursor.i - 1}) + } + } + // mark document changed and move the cursor + if (changed != null) { + this.changed() + } + if (new_cursor != null) { + this.move_cursor(new_cursor) + } else { + this.kill_cursor() + } +} +PeachHTML5Editor.prototype.on_page_up_key = function(e) { + var new_cursor, screen_y, scroll_amount + if (this.wrap2.scrollTop === 0) { + if (this.cursor == null) { + return + } + new_cursor = first_cursor_position(this.tree) + if (new_cursor != null) { + if (new_cursor.n !== this.cursor.n || new_cursor.i !== this.cursor.i) { + this.move_cursor(new_cursor) + } + } + return + } + if (this.cursor != null) { + screen_y = this.cursor.y - this.wrap2.scrollTop + } + scroll_amount = this.wrap2_height - breathing_room + this.wrap2.scrollTop = Math.max(0, this.wrap2.scrollTop - scroll_amount) + if (this.cursor != null) { + return this.move_cursor_into_view(screen_y + this.wrap2.scrollTop) + } +} +PeachHTML5Editor.prototype.on_page_down_key = function(e) { + var lowest_scrollpos, new_cursor, screen_y, scroll_amount + lowest_scrollpos = this.wrap2.scrollHeight - this.wrap2_height + if (this.wrap2.scrollTop === lowest_scrollpos) { + if (this.cursor == null) { + return + } + new_cursor = last_cursor_position(this.tree) + if (new_cursor != null) { + if (new_cursor.n !== this.cursor.n || new_cursor.i !== this.cursor.i) { + this.move_cursor(new_cursor) + } + } + return + } + if (this.cursor != null) { + screen_y = this.cursor.y - this.wrap2.scrollTop + } + scroll_amount = this.wrap2_height - breathing_room + this.wrap2.scrollTop = Math.min(lowest_scrollpos, this.wrap2.scrollTop + scroll_amount) + if (this.cursor != null) { + this.move_cursor_into_view(screen_y + this.wrap2.scrollTop) + } +} +PeachHTML5Editor.prototype.move_cursor_into_view = function(y_target) { + var cur, far_enough, finder, new_cursor, saved_ideal_x, was, y_max, y_min + if (y_target === this.cursor.y) { + return + } + was = this.cursor + y_min = this.wrap2.scrollTop + if (this.wrap2.scrollTop !== 0) { + y_min += breathing_room + } + y_max = this.wrap2.scrollTop + this.wrap2_height + if (this.wrap2.scrollTop !== this.wrap2.scrollHeight - this.wrap2_height) { // downmost + y_max -= breathing_room + } + y_target = Math.min(y_target, y_max) + y_target = Math.max(y_target, y_min) + if (y_target < this.cursor.y) { + finder = find_up_cursor_position + far_enough = function(cur, target_y) { + return cur.y + cur.h <= target_y + } + } else { + finder = find_down_cursor_position + far_enough = function(cur, y_target) { + return cur.y >= y_target + } + } + while (true) { + cur = finder(this.tree, was, this.cursor_ideal_x) + if (cur == null) { + break + } + if (far_enough(cur, y_target)) { + break + } + was = cur + } + if (was === this.cursor) { + was = null + } + if (was != null) { + if (was.y + was.h > y_max) { + was = null + } else if (was.y < y_min) { + was = null + } + } + if (cur != null) { + if (cur.y + cur.h > y_max) { + cur = null + } else if (cur.y < y_min) { + cur = null + } + } + if ((cur != null) && (was != null)) { + // both valid, pick best + if (cur.y < y_min) { + new_cursor = was + } else if (was.y + was.h > y_max) { + new_cursor = cur + } else if (cur.y - y_target < y_target - was.y) { + new_cursor = cur + } else { + new_cursor = was + } + } else { + new_cursor = was != null ? was : cur + } + if (new_cursor != null) { + saved_ideal_x = this.cursor_ideal_x + this.move_cursor(new_cursor) + this.cursor_ideal_x = saved_ideal_x + } +} +// remove all the editable content (and cursor, overlays, etc) +PeachHTML5Editor.prototype.clear_dom = function() { + while (this.idoc.body.childNodes.length) { + this.idoc.body.removeChild(this.idoc.body.childNodes[0]) + } + this.kill_cursor() +} +PeachHTML5Editor.prototype.load_html = function(html) { + this.tree = peach_parser(html, this.parser_opts) + if (this.tree[0] == null ? true : this.tree[0].parent == null) { + this.tree = peach_parser('

', this.parser_opts) + } + this.tree_parent = this.tree[0].parent + this.tree_parent.el = this.idoc.body + this.clear_dom() + instantiate_tree(this.tree, this.tree_parent.el) + this.collapse_whitespace(this.tree) + return this.changed() +} +PeachHTML5Editor.prototype.changed = function() { + this.in_el.onchange = null + this.in_el.value = this.pretty_html(this.tree) + this.in_el.onchange = (function(_this) { return function() { + return _this.load_html(_this.in_el.value) + }})(this) + return this.adjust_iframe_height() +} +PeachHTML5Editor.prototype.adjust_iframe_height = function() { + var h, s + s = this.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: + this.iframe.style.height = "10px" + h = parseInt(this.idoc.body.scrollHeight, 10) + this.iframe.style.height = h + "px" + return this.wrap2.scrollTop = s +} +// true if n is text node with only one caracter, and the only child of a tag +PeachHTML5Editor.prototype.is_only_char_in_tag = function(n, i) { + if (n.type !== 'text') { + return false + } + if (n.text.length !== 1) { + return false + } + if (n.parent === this.tree_parent) { + return false + } + if (n.parent.children.length !== 1) { + return false + } + return true +} +// true if n is text node with just a space in it, and the only child of a tag +PeachHTML5Editor.prototype.is_lone_space = function(n, i) { + if (n.type !== 'text') { + return false + } + if (n.text !== ' ') { + return false + } + if (n.parent === this.tree_parent) { + return false + } + if (n.parent.children.length !== 1) { + return false + } + 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 +PeachHTML5Editor.prototype.insert_should_replace = function(n, i) { + if (i !== 0) { + return false + } + if (n.text !== ' ') { + return false + } + if (n.parent === this.tree_parent) { + return true + } + if (n.parent.children.length === 1) { + if (n.parent.children[0] === n) { + // n is only child + return true + } + } + return false +} +// WARNING: after calling this, you MUST call changed() and text_cleanup() +PeachHTML5Editor.prototype.insert_character = function(n, i, char) { + if (n.parent === this.tree_parent) { + // FIXME implement text nodes at top level + return + } + // insert the character + if (this.insert_should_replace(n, i)) { + n.text = char + } else if (i === 0) { + n.text = char + n.text + } else if (i === n.text.length) { + n.text += char + } else { + n.text = n.text.substr(0, i) + char + n.text.substr(i) + } + return n.el.nodeValue = n.text +} +// WARNING: after calling this, you MUST call changed() and text_cleanup() +PeachHTML5Editor.prototype.remove_character = function(n, i) { + n.text = n.text.substr(0, i) + n.text.substr(i + 1) + return n.el.nodeValue = n.text +} +PeachHTML5Editor.prototype.computed_style = function(n, prop) { + var style + if (n.type === 'text') { + n = n.parent + } + style = this.iframe.contentWindow.getComputedStyle(n.el, null) + return style.getPropertyValue(prop) +} +// returns the new white-space value that will preserve spaces for node n +PeachHTML5Editor.prototype.preserve_space = function(n, ideal_target) { + var target, ws, ref + if (n.type === 'text') { + target = n.parent + } else { + target = n + } + while (target !== ideal_target && !target.el.style.whiteSpace) { + if (target == null) { + console.log("bug #967123") + return + } + target = target.parent + } + ws = (ref = ws_props[target.el.style.whiteSpace]) != null ? ref.to_preserve : null + if (ws == null) { + ws = 'pre-wrap' + } + target.el.style.whiteSpace = ws + this.update_style_from_el(target) + return ws +} +PeachHTML5Editor.prototype.update_style_from_el = function(n) { + var style + style = n.el.getAttribute('style') + if (style != null) { + return n.attrs.style = style + } else { + if (n.attrs.style != null) { + return delete n.attrs.style + } + } +} +// remove whitespace that would be trimmed +// replace whitespace that would collapse with a single space +// FIXME remove whitespace from after
(but not before) +// FIXME rewrite to +// check computed white-space prop on txt parents +// batch replace txt node contents (ie don't loop for each char) +PeachHTML5Editor.prototype.collapse_whitespace = function(tree) { + var cur, cur_i, cur_px, first, iterate, next, next_i, next_pos, next_px, operate, pos, prev, prev_i, prev_pos, prev_px, queue, remove, removed_char, replace_with_space + if (tree == null) { + tree = this.tree + } + prev = cur = next = null + prev_i = cur_i = next_i = 0 + prev_pos = pos = next_pos = null + prev_px = cur_px = next_px = null + first = true + removed_char = null + + tree_remove_empty_text_nodes(tree) + + iterate = function(tree, cb) { + var advance, block, i, j, n + for (j = 0; j < tree.length; j++) { + n = tree[j] + if (n.type === 'text') { + i = 0 + while (i < n.text.length) { // don't foreach, cb might remove chars + advance = cb(n, i) + if (advance) { + i += 1 + } + } + } + if (n.type === 'tag') { + block = is_display_block(n.el) + if (block) { + cb(null) + } + if (n.children.length > 0) { + iterate(n.children, cb) + } + if (block) { + cb(null) + } + } + } + } + // remove cur char + remove = function(undo) { + if (undo) { + cur.el.textContent = cur.text = (cur.text.substr(0, cur_i)) + removed_char + (cur.text.substr(cur_i)) + if (next === 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 === cur) { // in same text node + if (next_i === 0) { + throw "how is this possible?" + } + next_i -= 1 + } + return 1 + } + } + replace_with_space = function(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) + if (removed_char !== ' ') { + 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 = function() { + // cur definitately set + // prev and/or next might be null, indicating the start/end of a display:block + var bounds, dbg, fixer, fixers, i, need_undo, new_next_px, new_prev_px, removed, undo_arg + if (!is_space_code(cur.text.charCodeAt(cur_i))) { + return false + } + fixers = [remove, replace_with_space] + // check for common case: single whitespace surrounded by non-whitespace chars + if ((prev != null) && (next != null)) { + if (!((is_space_code(prev.text.charCodeAt(prev_i))) || (is_space_code(next.text.charCodeAt(next_i))))) { + dbg = cur.text.charCodeAt(cur_i) + if (cur.text.charAt(cur_i) === ' ') { + return false + } else { + fixers = [replace_with_space] + } + } + } + bounds = text_range_bounds(cur.el, cur_i, cur_i + 1) + // consistent cases: + // 1. zero rects returned by getClientRects() means collapsed space + if (bounds === null) { + return remove() + } + // 2. width greater than zero means visible space + if (bounds.w > 0) { + // has bounds, don't try removing + fixers = [replace_with_space] + } + // now the weird edge cases... + // + // firefox and chromium both report zero width for characters at the end + // of a line where the text wraps (automatically, due to word-wrap) to + // the next line. These do not appear to be distinguishable from + // collapsed spaces via the range/bounds api, so... + // + // remove it from the dom, and if prev or next moves, put it back. + // + // this block (try changing it, put it back if something moves) is also + // used on collapsable whitespace characters besides space. In this case + // the character is replaced with a normal space character instead of + // removed + if ((prev != null) && (prev_px == null)) { + prev_px = new_cursor_position({n: prev, i: prev_i}) + } + if ((next != null) && (next_px == null)) { + next_px = new_cursor_position({n: next, i: next_i}) + } + //if prev is null and next is null + // parent_px = cur.parent.el.getBoundingClientRect() + undo_arg = true // just for readabality + removed = 0 + for (i = 0; i < fixers.length; i++) { + fixer = fixers[i] + if (removed > 0) { + break + } + removed += fixer() + need_undo = false + if (prev != null) { + if (prev_px != null) { + new_prev_px = new_cursor_position({n: prev, i: prev_i}) + if (new_prev_px != null) { + if (new_prev_px.x !== prev_px.x || new_prev_px.y !== 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 != null) && !need_undo) { + if (next_px != null) { + new_next_px = new_cursor_position({n: next, i: next_i}) + if (new_next_px != null) { + if (new_next_px.x !== next_px.x || new_next_px.y !== 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 = function(n, i) { + var advance, removed + next = n + next_i = i + next_px = null + advance = true + if (cur != null) { + removed = operate() + // don't advance (to the next character next time) if we removed a + // character from the same text node as ``next``, because doing so + // renumbers the indexes in that string + if (removed && cur === next) { + advance = false + } + } else { + removed = false + } + if (!removed) { + prev = cur + prev_i = cur_i + prev_px = cur_px + } + cur = next + cur_i = next_i + cur_px = next_px + return advance + } + queue(null) + iterate(tree, queue) + queue(null) + + tree_remove_empty_text_nodes(tree) +} +// call this after you insert or remove inline nodes. It will: +// merge consecutive text nodes +// remove empty text nodes +// adjust white-space property +// note: this assumes that all whitespace in text nodes should be displayed +// (ie not collapse or be trimmed) and will change the white-space property +// as needed to achieve this. +PeachHTML5Editor.prototype.text_cleanup = function(n) { + var block, eats_start_sp, i, last, n_i, need_preserve, prev, prev_i, run, ws + if (this.is_display_block(n)) { + block = n + } else { + block = this.find_block_parent(n) + if (block == null) { + return + } + } + run = this.get_text_run(block) + if (run == null) { + return + } + if (run.length > 1) { + i = 1 + prev = run[0] + while (i < run.length) { + n = run[i] + if (prev.type === 'text' && n.type === 'text') { + if (prev.parent === n.parent) { + prev_i = n.parent.children.indexOf(prev) + n_i = n.parent.children.indexOf(n) + if (n_i === prev_i + 1) { + prev.text = prev.text + n.text + prev.el.textContent = prev.text + this.remove_node(n) + run.splice(i, 1) + continue // don't increment i or change prev + } + } + } + i += 1 + prev = n + } + } + // remove empty text nodes + i = 0 + while (i < run.length) { + n = run[i] + if (n.type === 'text') { + if (n.text === '') { + this.remove_node(n) + // FIXME maybe remove parents recursively if this makes them empty + run.splice(i, 1) + continue // don't increment i + } + } + i += 1 + } + // note: inline tags can have white-space:pre-line/etc + // note: inline-blocks have their whitespace collapsed independantly of outer run + // note: inline-blocks are treated like non-whitespace char even if empty + if (block.el.style.whiteSpace != null) { + ws = block.el.style.whiteSpace + if (ws_props[ws]) { + if (ws_props[ws].space) { + if (ws_props[ws].to_collapse === 'normal') { + block.el.style.whiteSpace = null + } else { + block.el.style.whiteSpace = ws_props[ws].to_collapse + } + this.update_style_from_el(block) + } + } + } + // note: space after
colapses, but not space before + // check for spaces that would collapse without help + eats_start_sp = true // if the next node starts with space it collapses (unless pre) + prev = null + for (i = 0; i < run.length; ++i) { + n = run[i] + if (n.type === 'tag') { + if (n.name === 'br') { + eats_start_sp = true + } else { + eats_start_sp = false + } + } else { + need_preserve = false + if (n.type !== 'text') { + console.log("bug #232308") + return + } + if (eats_start_sp) { + if (is_space_code(n.text.charCodeAt(0))) { + need_preserve = true + } + } + if (!need_preserve) { + need_preserve = multi_sp_regex.test(n.text) + } + if (need_preserve) { + // do we have it already? + ws = this.computed_style(n, 'white-space') // FIXME implement this + if (ws_props[ws] == null ? true : ws_props[ws].space == null) { + // 2nd arg is ideal target for css rule + ws = this.preserve_space(n, block) + } + eats_start_sp = false + } else { + if (is_space_code(n.text.charCodeAt(n.text.length - 1))) { + ws = this.computed_style(n, 'white-space') // FIXME implement this + if ((ref1 = ws_props[ws]) != null ? ref1.space : void 0) { + eats_start_sp = false + } else { + eats_start_sp = true + } + } else { + eats_start_sp = false + } + } + } + } + // check if text ends with a collapsable space + if (run.length > 0) { + last = run[run.length - 1] + if (last.type === 'text') { + if (eats_start_sp) { + this.preserve_space(last, block) + } + } + } +} +PeachHTML5Editor.prototype.css_clear = function(n, prop) { + var css_delimiter_regex, i, styles + if (n.attrs.style == null) { + return + } + if (n.attrs.style === '') { + return + } + css_delimiter_regex = new RegExp('\s*;\s*', 'g') // FIXME make this global + styles = n.attrs.style.trim().split(css_delimiter) + if (!(styles.length > 0)) { + return + } + if (styles[styles.length - 1] === '') { + styles.pop() + if (!(styles.length > 0)) { + return + } + } + i = 0 + while (i < styles.length) { + if (styles[i].substr(0, 12) === 'white-space:') { + styles.splice(i, 1) + } else { + i += 1 + } + } +} +// WARNING: after calling this one or more times, you MUST: +// if it's inline: call @text_cleanup +// call @changed() +PeachHTML5Editor.prototype.remove_node = function(n) { + var i + i = n.parent.children.indexOf(n) + if (i === -1) { + throw "BUG #9187112313" + } + n.el.parentNode.removeChild(n.el) + n.parent.children.splice(i, 1) +} +// remove a node from the tree/dom, insert into new_parent before insert_before?end +// WARNING: after calling this one or more times, you MUST: +// if it's inline: call @text_cleanup +// call @changed() +PeachHTML5Editor.prototype.move_node = function(n, new_parent, insert_before) { + var before_i, i + if (insert_before == null) { + insert_before = null + } + i = n.parent.children.indexOf(n) + if (i === -1) { + throw "Error: tried to remove node, but it's not in it's parents list of children" + return + } + if (insert_before != null) { + before_i = new_parent.children.indexOf(insert_before) + if (i === -1) { + throw "Error: tried to move a node to be before a non-existent node" + } + insert_before = insert_before.el + } + this.remove_node(n) + if (insert_before != null) { + new_parent.el.insertBefore(n.el, insert_before) + new_parent.children.splice(before_i, 0, n) + } else { + new_parent.el.appendChild(n.el, insert_before) + new_parent.children.push(n) + } + n.parent = new_parent +} +// remove it, forget where it was +PeachHTML5Editor.prototype.kill_cursor = function() { + if (this.cursor_visible) { + this.cursor_el.parentNode.removeChild(this.cursor_el) + this.cursor_visible = false + } + this.cursor = null + this.annotate(null) +} +PeachHTML5Editor.prototype.move_cursor = function(cursor) { + var height + this.cursor_ideal_x = cursor.x + this.cursor = cursor + if (!this.cursor_visible) { + this.cursor_el = domify(this.outer_idoc, {div: { id: 'cursor'}}) + this.overlay.appendChild(this.cursor_el) + this.cursor_visible = true + } + this.cursor_el.style.left = (cursor.x + overlay_padding - 1) + "px" + if (cursor.h < 5) { + height = 12 + } else { + height = cursor.h + } + this.cursor_el.style.top = (cursor.y + overlay_padding + Math.round(height * .07)) + "px" + this.cursor_el.style.height = (Math.round(height * 0.82)) + "px" + this.annotate(cursor.n) + this.scroll_into_view(cursor.y, height) +} +PeachHTML5Editor.prototype.scroll_into_view = function(y, h) { + var downmost, upmost + if (h == null) { + h = 0 + } + y += overlay_padding // convert units from @idoc to @wrap2 + // very top of document + if (y <= breathing_room) { + this.wrap2.scrollTop = 0 + return + } + // very bottom of document + if (y + h >= this.wrap2.scrollHeight - breathing_room) { + this.wrap2.scrollTop = this.wrap2.scrollHeight - this.wrap2_height + return + } + // The most scrolled up (lowest value for scrollTop) that would be OK + upmost = y + h + breathing_room - this.wrap2_height + upmost = Math.max(upmost, 0) + // the most scrolled down (highest value for scrollTop) that would be OK + downmost = y - breathing_room + downmost = Math.min(downmost, this.wrap2.scrollHeight - this.wrap2_height) + if (upmost > downmost) { // means h is too big to fit + // scroll so top is visible + this.wrap2.scrollTop = downmost + return + } + if (this.wrap2.scrollTop < upmost) { + this.wrap2.scrollTop = upmost + return + } + if (this.wrap2.scrollTop > downmost) { + this.wrap2.scrollTop = downmost + return + } +} +PeachHTML5Editor.prototype.annotate = function(n) { + var alpha, ann_box, ann_tag, bounds, prev_bounds + while (this.matting.length > 0) { + this.overlay.removeChild(this.matting[0]) + this.matting.shift() + } + if (n == null) { + return + } + prev_bounds = {x: 0, y: 0, w: 0, h: 0} + alpha = 0.1 + while (((n != null ? n.el : void 0) != null) && n !== this.tree_parent) { + if (n.type === 'text') { + n = n.parent + continue + } + bounds = get_el_bounds(n.el) + if (bounds == null) { + return + } + if (bounds.x === prev_bounds.x && bounds.y === prev_bounds.y && bounds.w === prev_bounds.w && bounds.h === prev_bounds.h) { + n = n.parent + continue + } + ann_box = domify(this.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}); + this.overlay.appendChild(ann_box) + this.matting.push(ann_box) + ann_tag = domify(this.outer_idoc, {div: {"class": 'ann_tag', style: "left: " + (bounds.x + 1 + overlay_padding) + "px; top: " + (bounds.y - 7 + overlay_padding) + "px",children: [domify(this.outer_idoc, {text: " " + n.name + " "})]}}) + this.overlay.appendChild(ann_tag) + this.matting.push(ann_tag) + n = n.parent + alpha *= 1.5 + } +} +PeachHTML5Editor.prototype.pretty_html = function(tree, indent, parent_flags) { + var attr_keys, cs, display, float, i, j, in_flow, in_flow_block, inner_flags, is_block, is_br, is_text, k, n, next_indent, position, prev_in_flow_is_block, prev_in_flow_is_text, ret, visibility, want_nl, whitespace + if (indent == null) { + indent = '' + } + if (parent_flags == null) { + parent_flags = { + pre_ish: false, + block: true, + want_nl: false + } + } + ret = '' + want_nl = parent_flags.want_nl + prev_in_flow_is_text = false + prev_in_flow_is_block = false + for (i = 0; i < tree.length; ++i) { + n = tree[i] + inner_flags = { + want_nl: true + } + is_br = false + switch (n.type) { + case 'tag': + if (n.name === 'br') { + is_br = true + } + is_text = false + if (n.el.currentStyle != null) { + cs = n.el.currentStyle + whitespace = cs['white-space'] + display = cs['display'] + position = cs['position'] + float = cs['float'] + visibility = cs['visibility'] + } else { + cs = this.iframe.contentWindow.getComputedStyle(n.el, null) + whitespace = cs.getPropertyValue('white-space') + display = cs.getPropertyValue('display') + position = cs.getPropertyValue('position') + float = cs.getPropertyValue('float') + visibility = cs.getPropertyValue('visibility') + } + if (n.name === 'textarea') { + inner_flags.pre_ish = true + } else { + inner_flags.pre_ish = whitespace.substr(0, 3) === 'pre' + } + switch (float) { + case 'left': + case 'right': + in_flow = false + break + default: + switch (position) { + case 'absolute': + case 'fixed': + in_flow = false + break + default: + if ('display' === 'none') { + in_flow = false + } else { + switch (visibility) { + case 'hidden': + case 'collapse': + in_flow = false + break + default: + in_flow = true + } + } + } + } + switch (display) { + case 'inline': + case 'none': + inner_flags.block = false + is_block = in_flow_block = false + break + case 'inline-black': + inner_flags.block = true + is_block = in_flow_block = false + break + default: + inner_flags.block = true + is_block = true + in_flow_block = in_flow + } + break + case 'text': + is_text = true + is_block = false + in_flow = true + in_flow_block = false + break + default: // 'comment', 'doctype' + is_text = false + is_block = false + in_flow = false + in_flow_block = false + } + // print whitespace if we can + if (!parent_flags.pre_ish) { + if (!(prev_in_flow_is_text && is_br)) { + if ((i === 0 && parent_flags.block) || in_flow_block || prev_in_flow_is_block) { + if (want_nl) { + ret += "\n" + } + ret += indent + } + } + } + switch (n.type) { + case 'tag': + ret += '<' + n.name + attr_keys = [] + for (k in n.attrs) { + attr_keys.unshift(k) + } + //attr_keys.sort() + for (j = 0; j < attr_keys.length; ++j) { + k = attr_keys[j] + ret += " " + k + if (n.attrs[k].length > 0) { + ret += "=\"" + (enc_attr(n.attrs[k])) + "\"" + } + } + ret += '>' + if (void_elements[n.name] == null) { + if (inner_flags.block) { + next_indent = indent + ' ' + } else { + next_indent = indent + } + if (n.children.length) { + ret += this.pretty_html(n.children, next_indent, inner_flags) + } + ret += "" + } + break + case 'text': + ret += enc_text(n.text) + break + case 'comment': + ret += "" // TODO encode? + break + case 'doctype': + ret += " 0) { + ret += " \"" + n.public_identifier + "\"" + } + if ((n.system_identifier != null) && n.system_identifier.length > 0) { + ret += " \"" + n.system_identifier + "\"" + } + ret += ">" + } + want_nl = true + if (in_flow) { + prev_in_flow_is_text = is_text + prev_in_flow_is_block = is_block || (in_flow && is_br) + } + } + if (tree.length) { + // output final newline if allowed + if (!parent_flags.pre_ish) { + if (prev_in_flow_is_block || parent_flags.block) { + ret += "\n" + (indent.substr(4)) + } + } + } + return ret +} +PeachHTML5Editor.prototype.onblur = function() { + this.kill_cursor() +} +PeachHTML5Editor.prototype.have_focus = function() { + this.editor_is_focused = true + this.poll_for_blur() +} +PeachHTML5Editor.prototype.poll_for_blur = function() { + if (this.poll_for_blur_timeout != null) { + return + } + this.poll_for_blur_timeout = timeout(150, (function(_this) { return function() { + next_frame(function() { // pause polling when browser knows we're not active/visible/etc. + _this.poll_for_blur_timeout = null + if (document.activeElement === _this.outer_iframe) { + _this.poll_for_blur() + } else { + _this.editor_is_focused = false + _this.onblur() + } + }) + }})(this)) +} + +window.peach_html5_editor = function() { + // coffeescript: return new PeachHTML5Editor args... + // compiles to below... there must be a better way + var args + args = 1 <= arguments.length ? slice.call(arguments, 0) : [] + return (function(func, args, ctor) { + ctor.prototype = func.prototype + var child = new ctor, result = func.apply(child, args) + return Object(result) === result ? result : child + })(PeachHTML5Editor, args, function(){}) +} + +}).call(this) + +// test in browser: peach_html5_editor(document.getElementsByTagName('textarea')[0])