/* Copyright (c) 2011, Geert Bergman (geert@scrivo.nl) * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * 1. Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. * 3. Neither the name of "Scrivo" nor the names of its contributors may be * used to endorse or promote products derived from this software without * specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. * * $Id: BaseHTMLEditControl.js 152 2011-12-08 00:30:17Z geert $ */ "use strict"; SUI.control.BaseHTMLEditControl = SUI.defineClass( /** @lends SUI.control.BaseHTMLEditControl.prototype */{ /** @ignore */ baseClass: SUI.Box, /** * @class * SUI.control.BaseHTMLEditControl implements a WYSIWIG/HTML edit control. It * should provide a standardized interface to the different implementations * of contenteditable in various browsers. Because of code size of the * class it is splitted in two parts: this is BaseHTMLEditControl and * implements all of the technical structure, the other part * HTMLEditControl only implements the actions that can be performed on * the content. * * @augments SUI.Box * * @description * SUI.control.BaseHTMLEditControl constructor. This base class was created * to limit code size. You don't need this constructor HTMLEditControl is * the one you need. * * @constructs * * @param {inherit} arg * @param {boolean} arg.ieStrictMode Whether strict mode should be used * in IE * @param {Function} arg.onCommandExecuted event handler, see * onCommandExecuted() * @param {Function} arg.onContextMenu event handler, see onContextMenu() * @param {Function} arg.onFocus event handler, see onFocus() * @param {Function} arg.onKeyDown event handler, see onKeyDown() * @param {Function} arg.onLoad event handler, see onLoad() * @param {Function} arg.onSelectionChange event handler, see * onSelectionChange() * * @protected */ initializer: function(arg) { SUI.control.BaseHTMLEditControl.initializeBase(this, arg); var that = this; // if the editor document is in strict mode, default true if (arg.ieStrictMode !== undefined) { this._ieStrictMode = arg.ieStrictMode; } // Set the event handlers if (arg.onLoad) { this.addListener("onLoad", arg.onLoad); } if (arg.onCommandExecuted) { this.addListener("onCommandExecuted", arg.onCommandExecuted); } if (arg.onContextMenu) { this.addListener("onContextMenu", arg.onContextMenu); } if (arg.onFocus) { this.addListener("onFocus", arg.onFocus); } if (arg.onKeyDown) { this.addListener("onKeyDown", arg.onKeyDown); } if (arg.onSelectionChange) { this.addListener("onSelectionChange", arg.onSelectionChange); } // Create the iframe for the contenteditable this.iframe = SUI.browser.createElement("IFRAME"); this.iframe.frameBorder = 0; //frameborder="0" // Set the onload handler on which we further initialize the // contenteditable SUI.browser.addEventListener(this.iframe, "load", function(e) { if (!that._onLoadIframe(new SUI.Event(this, e))) { SUI.browser.noPropagation(e); } }); if (SUI.browser.isIE) { SUI.browser.addEventListener(this.iframe, "blur", function(e) { that.bmk = that.tmpBmk; }); } // Add a SUI style class SUI.style.addClass(this.iframe, "sui-scrivo-he"); // Set the initial content for the iframe: strict, UTF-8 and base var doctype = "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.1 Strict//EN\">\n"; // Don't use strict doctype in IE if requested if (SUI.browser.isIE && ! this._ieStrictMode) { doctype = ""; } // Apparently IE 8 does not support setting the charset through the // meta tag when using https (navigation cancelled). Do this later. var charset = "<meta http-equiv=\"content-type\"" + " content=\"text/html; charset=UTF-8\">"; if (SUI.browser.isIE && SUI.browser.version < 9) { charset = ""; } this.iframe.src = "javascript: '" + doctype + "<html>" + "<head>" + charset + "<base href=\""+SCRIVO_BASE_DIR+"/\">" + "</head>" + "<body spellcheck=\"false\">" + "</body>" + "</html>'"; // And append the iframe to our SUI.Box container this.el().appendChild(this.iframe); // Initialize the object that manages the paste actions this._pasteData = new SUI.control.PasteData({ editor: this }); }, /** * Set the focus to the editable region. */ focus: function() { if (this._canFocus && !this._pasteData._intercepting) { if (SUI.browser.isIE) { if (this._focussedElement) { this._focussedElement.focus(); } else { this._editDoc.body.focus(); } if (!this.focussed && this.bmk) { this.focussed = true; this._ieRestoreBookmark(this.bmk); this.bmk = null; } } else { this._editDoc.body.focus(); } } }, /** * Get the block format at the current cursor position. * @return {String} tag name of the block where the cursor is located */ getCurrentBlockFormat: function() { var bFmt = this._editDoc.queryCommandValue("FormatBlock"); var fmt = this._blockFormats[bFmt]; return (fmt === undefined) ? "" : fmt; }, /** * Get a reference to the document object of the page in the editor * @return {HTMLElementNode} document element of the page being edited */ getDocument: function() { return this._editDoc; }, /** * Get the element that's currently selected in the editor * @return {HTMLElementNode} the element node currently selected in the * editor */ getSelectedElement: function() { return this._getParentElementSelection(this._getSelection()); }, /** * Get HTML content for the edit control. * Throws {ReferenceError} if the editor was not fully loaded yet * @return {String} HTML data fragment from editor (no a top container) */ getValue: function() { if (!this._editDoc) { throw new ReferenceError( "getValue failed because the editor was not fully loaded yet"); } for (var i=0; i<this._editableElements.length; i++) { this._editableElements[i].contentEditable = false; } var data = this._editDoc.body.innerHTML; for (var i=0; i<this._editableElements.length; i++) { this._editableElements[i].contentEditable = true; } return data; }, /** * Is the editor document in strict mode. This value can only be false in * IE, it returns always true (and also not affects) other browsers. * @return {boolean} Whether the editor document is in strict mode. */ ieStrictMode: function() { return this._ieStrictMode; }, /** * Tell the HTML edit control which block formats you want to use * @param {string[]} blockFormats array in which each element has a value * key containing a block tag (P, H1 ... H6) */ initBlockFormats: function(blockFormats) { // if already initialized if (this._blockFormats) { return; } this._blockFormats = {}; if (SUI.browser.isIE) { // Find the block format names. IE returns internationalized // results on the queryCommandValue("FormatBlock") command // ('Kop 1' instead of 'Header 1' for example), so we need // these to map them back to HTML tags. Other browsers seem // to return just the tag name. this._blockFormats[""] = ""; // create an offscreen tmp div var tmp = SUI.browser.createElement(); tmp.unselectable = false; SUI.style.setRect(tmp, 10, -110, 100, 100); tmp.contentEditable= true; document.body.appendChild(tmp); // for all given block elements ... for (var i=1; i<blockFormats.length; i++) { // ... try to make a block element of that type ... try { var el = document.createElement(blockFormats[i].value); el.innerHTML = "dummy"; } catch (e) { continue; } // ... add it to our tmp div ... tmp.appendChild(el); // ... and focus an select the block element, ... try { tmp.focus(); } catch(e) {} document.execCommand("SelectAll", false, null); // ... now get the internationalized name and use that as key // in _blockFormats var bFmt = document.queryCommandValue("FormatBlock"); this._blockFormats[bFmt] = blockFormats[i].value; // remove the dummy node tmp.removeChild(tmp.firstChild); } // we're done with the div document.body.removeChild(tmp); } else { // in other browsers we can use the tag name for (var i=1; i<blockFormats.length; i++) { this._blockFormats[blockFormats[i].value.toLowerCase()] = blockFormats[i].value; } } }, /** * SUI framework layOut method */ layOut: function() { // Size the host of the iframe (a SUI.Box) SUI.control.BaseHTMLEditControl.parentMethod(this, "layOut"); // and size the contained iframe to the size of this box SUI.style.setRect( this.iframe, 0, 0, this.width(), this.height()); }, /** * Fires after an editor command is executed (and content is changed), an * important trigger for updating your user interface. */ onCommandExecuted: function() { }, /** * Fires when the user selects the wrong mouse button * @param {int} x x-offset (downwards) of the mouse click * @param {int} y y-offset of the mouse click */ onContextMenu: function(x, y) { }, /** * Fires when the document body in the editor window (iframe) receives * the focus. * @param {SUI.Event} evnt focus event */ onFocus: function(evnt) { }, /** * Fires when the user presses (the) any key * @param {SUI.Event} evnt keydown event */ onKeyDown: function(evnt) { }, /** * Fires after the content is set into the editor with setValue */ onLoad: function() { }, /** * Fires when the user selects (moves it cursor) to an other element * in the DOM tree, an important trigger for updating your user interface. */ onSelectionChange: function() { }, /** * Get or set the paste method to use wehn pasting. * @param {string} [p] The desired method for pasting text: 'text', * 'filtered' or 'html' (or none to use this method as a getter). * @return {string} The current paste method setting (or null if this * method was used as a setter). */ pasteMethod: function(p) { return this._pasteData.pasteMethod(p); }, /** * Clear the contents of the control */ resetIframe: function() { // older FireFoxes need this very hard reset to empty the // contenteditable (and to keep working). Editor cursor is still // visible but editing is not possible. It is likely that this patch // is not necessary. As reported , bug 504268 occurs when // contenteditable elements are hidden and redisplayed. At this moment // the contenteditable elements are always rebuild and not reused. if (SUI.browser.isGekco) { this._editDoc = null; this.el().removeChild(this.iframe); this.el().appendChild(this.iframe); } else { if (this._editDoc) { this._editDoc.body.innerHTML = ""; } } }, /** * Force an onload event to happen to (Gecko refuses to do an onload if * the iframe in initially hidden, so you can use this) */ geckoForceOnloadEvent: function() { if (!this._loaded && SUI.browser.isGecko) { // Correct way: // var onload = this._editDoc.createEvent("HTMLEvents"); // onload.initEvent("load", true, true); // this.iframe.dispatchEvent(onload); // forces an event, but still the error occurs. A very blunt // approach seems to do the trick well. this.el().removeChild(this.iframe); this.el().appendChild(this.iframe); } }, /** * Supply CSS data for the edit control. The current CSS style code and * linked stylesheets are removed. First the external style sheets are * linked in the order they are provided, then the CSS code is appended. * @param {String} styleDef CSS code * @param {String} classNameBody A CSS class name for the document body. * @param {string[]} styleSheets array of CSS stylesheet links */ setCSS: function(styleDef, classNameBody, styleSheets) { if (!this._editDoc) { // If the contenteditable iframe is not fully loaded store // arguments for later use this._tmpStoreStyleSheet = []; for (var i=0; i<arguments.length; i++) { this._tmpStoreStyleSheet.push(arguments[i]); } for (; i<3; i++) { this._tmpStoreStyleSheet.push(null); } } else { classNameBody = classNameBody || ""; if (classNameBody != "") { // Set class attribute of editable body SUI.style.addClass(this._editDoc.body, classNameBody); } var head = this._editDoc.getElementsByTagName("head")[0]; // Remove the old style sheets ... var l = head.getElementsByTagName("LINK"); for (var i=0; i<l.length; i++) { head.removeChild(l[i]); } // ... and append the new ones if (styleSheets) { for (i=0; i<styleSheets.length; i++) { var l = this._editDoc.createElement("LINK"); l.type = "text/css"; l.rel = "stylesheet"; l.href = styleSheets[i]; head.appendChild(l); } } var id_scrivo_styles = "scrivo_styles"; // If we've written CSS code earlier, remove it var st = this._editDoc.getElementById(id_scrivo_styles); if (st) { head.removeChild(st); } // Now create the CSS code (add scrivo system styles last) ... var cssText = (styleDef ? styleDef : "") + "\n" + this._createSystemCSS(); // ... create a style sheet ... var st = this._editDoc.createElement("STYLE"); st.type = "text/css"; st.id = id_scrivo_styles; if (SUI.browser.isIE) { st.styleSheet.cssText = cssText; } else { // w3c st.appendChild(this._editDoc.createTextNode(cssText)); } // ... and add it to the editable document head.appendChild(st); } }, /** * TODO * @param {int[]} ids The editable ids */ setEditableElementIds: function(ids) { this._editableElementIds = ids; }, /** * Set HTML content for the edit control. Calls the onload handler when * the data is fully loaded. * @param {String} val HTML data fragment (no need for a top container) */ setValue: function(val) { if (!this._editDoc) { // If the contenteditable iframe is not fully loaded store // arguments for later use this._tmpStore = val; } else { // Setting the contenteditable to false before assigning a value // prevents the action to be pushed onto the undo stack (as // seen on IE8) this._setContentEditable(false); this._editDoc.body.innerHTML = val; this._setContentEditable(true); // This patch was needed for FF 3.6: After loading the content // editor seemed partially locked: all delete functionality // like delete, backspace, execCommand('delete') but also // execCommand('insertHTML') did not work. In the later case the // content was inserted, but the current selection was not // deleted). Inserting content by for instance typing but also // after pasting HTML, corrected the situation. if (SUI.browser.isGecko) { this._initGecko(); } // images are not very well selectable in WebKit, so fix that this._webKitImgFix(); // now we're ready loading the content: call clients onload this.callListener("onLoad"); } }, // Holds a list of block formats with keys as // queryCommandValue("FormatBlock") returns _blockFormats: null, // Gecko has some layouting problems after using focus en execCommand in // init fase _canFocus: true, // an array with the id's of the editable elements (none defined -> the // document body will be editable. _editableElementIds: [], // an array containt references to the editable elements. _editableElements: [], // reference to the contenteditable iframe's document _editDoc: null, // reference to the contenteditable iframe's window _editWin: null, // the element that is focussed to set the focus back (IE when using // multiple editable regions). _focussedElement: null, // queryCommandEnabled("paste") trigger onbeforepaste event what we want // to prevent _ieDoBeforePaste: true, // display documents in scrict mode in IE (always true for other browsers), // you'll encounter several problems if you do so: cursor problems when // navigating with error keys, collapsing document element and possibly // more _ieStrictMode: true, // We need to save some parameters to detect onselectionchange _lastSel: { cllps: null, //< trigger value, normally true/false elem: null, cnt: 0 //< problems with -> after bold }, // To check if the onload has happend (FF has this bug with initially // hidden content divs) _loaded: false, // Utility object that helps us to intercept to user's paste actions // in order to clean the data before it is inserted in the content _pasteData: null, // location of scrivo library _scrivoDir: "scrivo", // Temporary storage for content while waiting for onload to finish _tmpStore: null, // Temporary storage for css data while while waiting for onload to finish _tmpStoreStyleSheet: null, // binary or: canUndo = 1, canRedo = 2 _undoState: 0, /** * Construct the system stylesheet. The system stylesheet gives the user * visual feedback of 'invisible' things in the browser, like language * marks, anchors, misspelled words etc. */ _createSystemCSS: function() { return ( ".sys_language {\n" + " border: solid gray 1px;\n" + " background-color: #FFFFEE;\n" + " padding-left: 18px;\n" + " padding-right: 2px;\n" + " background-image: url(\"" /* + this._scrivoDir + "/sui/" */ + SUI.imgDir + "/" + SUI.resource.ecLang + "\");\n" + " background-repeat: no-repeat;\n" + "}\n" + ".sys_abbr {\n" + " border: solid gray 1px;\n" + " background-color: #FFFFEE;\n" + " padding-left: 18px;\n" + " padding-right: 2px;\n" + " background-image: url(\"" /* + this._scrivoDir + "/sui/" */ + SUI.imgDir + "/" + SUI.resource.ecAbbr + "\");\n" + " background-repeat: no-repeat;\n" + "}\n" + ".sys_anchor {\n" + " border: solid gray 1px;\n" + " background-color: #FFFFEE;\n" + " padding-left: 18px;\n" + " padding-right: 2px;\n" + " background-image: url(\"" /* + this._scrivoDir + "/sui/" */ + SUI.imgDir + "/" + SUI.resource.ecAnchor+ "\");\n" + " background-repeat: no-repeat;\n" + "}\n" + "table tr .sys_table_head {\n" + " background-color: #DD0000;\n" + " color: #FFFF00;\n" + "}\n" + "table tr .sys_table_axis {\n" + " background-color: #00CC00;\n" + " color: #FFFF00;\n" + "}\n" + "table tr .sys_show_cell {\n" + " border-bottom: dashed #C0C0C0 1px;\n" + " border-right: dashed #C0C0C0 1px;\n" + "}\n" + ".sys_spell {" + " border-bottom: solid #D00000 2px;\n" + "}\n" + ".sys_spell_suggestions {\n" + " border-bottom: dashed #D00000 2px;\n" + "}\n" + ".sys_spell_compoundword {\n" + " border-bottom: dashed #00D000 2px;\n" + "}\n"); }, /** * Excecute standard contenteditable commands */ _execCommand: function(id, opt){ // first set the focus back, it is likely that we lost it this.focus(); if (SUI.browser.isIE) { // IE: if there is a selection run execCommand of the // selection else run execCommand of the document var sel = this._editDoc.selection.createRange(); if (this._editDoc.selection.type == "None") { this._editDoc.execCommand(id, false, opt); } else { sel.execCommand(id, false, opt); } } else { this._editDoc.execCommand(id, false, opt); } }, /** * execCommand fontsize is not supported very well. Therefore we use * fontname instead. Non IE browsers will insert a span and we search * for these spans and set to font size with the value set in the * font family field. */ _spanFix: function () { // Get all the font tags ... var l = this._editDoc.getElementsByTagName("SPAN"); // ... and work your way downwards (upwards in the document) for (var i=l.length-1; i>=0; i--) { // Try to get the face attribute var fce = l[i].style.fontFamily; if (fce) { // Yes: first check the first two characters of the value // (allow for a quote) var of = (fce.charAt(0)=="'" || fce.charAt(0)=='"') ? 1 : 0; if (fce.substr(of, 2) == "s_") { l[i].style.fontSize = fce.replace("s_", "").replace("P", "%"); l[i].style.fontFamily = ""; } } } }, /** * The IE editor inserts font tags in the content. And since we like spans * better we replace then. Note that we cripple the face attribute * (f_Roman and s_100%). So we correct that too. */ _fontFix: function () { // Get all the font tags ... var l = this._editDoc.getElementsByTagName("FONT"); // ... and work your way downwards (upwards in the document) for (var i=l.length-1; i>=0; i--) { // Try to get the face attribute var fce = l[i].getAttribute("face"); if (fce) { // Yes: replace font or size with a span, first check the // first two characters of the value (allow for a quote) var of = (fce.charAt(0)=="'" || fce.charAt(0)=='"') ? 1 : 0; switch (fce.substr(of, 2)) { case "f_": var sp = this._editDoc.createElement("SPAN"); sp.innerHTML = l[i].innerHTML; sp.style.fontFamily = fce.replace("f_", ""); this._replaceNode(l[i], sp); break; case "s_": var sp = this._editDoc.createElement("SPAN"); sp.innerHTML = l[i].innerHTML; sp.style.fontSize = fce.replace("s_", "").replace("P", "%"); this._replaceNode(l[i], sp); break; } } else { // No: try to get the color attribute var fce = l[i].getAttribute("color"); if (fce) { // replace with a colored span var sp = this._editDoc.createElement("SPAN"); sp.innerHTML = l[i].innerHTML; sp.style.color = fce; this._replaceNode(l[i], sp); } } } }, /** * Get the current range object from the current selection in the editor * (param sel is null) or a given selection. */ _getCurrentRange: function(sel) { if (!sel) { sel = this._getSelection(); } if (sel) { if (SUI.browser.isIE) { // IE return sel.createRange(); } if(sel.rangeCount && sel.getRangeAt) { // W3C return sel.getRangeAt(0); } } return null; }, /** * Get the parent element node (no text node) of a given selection. * @param sel A selection */ _getParentElementSelection: function (sel) { // In IE when there's no selection in the editor the document needs // to have the focus to determine the parentElement. if (SUI.browser.isIE && sel.type=="None") { this.focus(); } var r = this._getCurrentRange(sel); if (r) { if (SUI.browser.isIE) { if (sel.type=="Control") { return SUI.browser.version < 9 ? r.commonParentElement() : r.item(0); } return r.parentElement(); } else { var n = r.commonAncestorContainer; while(n.nodeType != 1 && n.nodeType != 9){ n = n.parentNode; } return n; } } return null; }, /** * Get the current selection in the editor */ _getSelection: function() { return this._editDoc.selection ? this._editDoc.selection // IE : this._editWin.getSelection(); // W3C }, /** * In IE's case we don't want to get the event from the window object * but the iframe's window object instead */ _ieEvent: function(e) { return SUI.browser.isIE && SUI.browser.version < 9 ? this._editWin.event : e; }, /** * Finding text in IE using their range object */ _ieFind: function(what, caseSensitive, backward, wholeWord) { // build the find flag value var flg = 0; if (backward) { flg += 1; } if (wholeWord) { flg += 2; } if (caseSensitive) { flg += 4; } // get a range object for searching var rng = this._editDoc.selection.createRange(); // collapse in the search direction rng.collapse(backward); // and search the text var strFound = rng.findText(what, 10000000, flg); // select the text when found if (strFound) { rng.select(); } return strFound; }, _ieRestoreBookmark: function(bmk) { // ... and restore the selection var rng = null; if (bmk) { if (bmk.type === "Range") { rng = bmk.value; } else if (bmk.type === "Bookmark") { // Use IEs bookmark facility to restore the selection rng = this._editDoc.body.createTextRange(); rng.moveToBookmark(bmk.value); } else { // Get the control range ... rng = this._editDoc.body.createControlRange(); // ... and clear it ... while (rng.length) { rng.remove(0); } // ... add the stored value to the range and select it rng.add(bmk.value); } } if (rng) { rng.select(); } return rng; }, _ieSaveBookmark: function(useBookmark) { var bmk = null; var range = this._editDoc.selection.createRange(); if (this._editDoc.selection.type === "Control") { // An element was selected bmk = { type: "Control", value: range.item(0) }; } else { if (useBookmark) { // Use IEs bookmark facility to store the selection if (range.getBookmark) { bmk = { type: "Bookmark", value: range.getBookmark() }; } } else { bmk = { type: "Range", value: range }; } } return bmk; }, /** * Gecko specific initialization for the contenteditable iframe */ _initGecko: function() { // prevent Gecko layOut problems this._canFocus = false; this._execCommand("enableInlineTableEditing", false); this._execCommand("enableObjectResizing", false); this._execCommand("insertbronreturn", false); this._execCommand("styleWithCSS", false); this._canFocus = true; // remove '_moz_editor_bogus_node' br from editor content ... if (this._editDoc.body.firstChild && this._editDoc.body.firstChild.nodeType == 1) { // ... however this seems not really necessary in our case (due // to the method of providing initial document?) if (this._editDoc.body.firstChild.getAttribute( "_moz_editor_bogus_node")) { this._editDoc.body.removeChild(this._editDoc.body.firstChild); if (this._editDoc.body.innerHTMLlength <= 1) { this._editDoc.body.innerHTML = "<p> </p>"; } } } }, /** * IE specific initialization for the contenteditable iframe */ _initIE: function() { var that = this; // We only want execute _onSelectionChangeIE if the control is focussed this.focussed = null; if (SUI.browser.version < 9) { // IE onselectionchange, fired when we're selecting other DOM nodes in // the editor. Really usefull for enabling editor buttons for instance. // We're trying to mimic this behavior also on other browsers. SUI.browser.addEventListener(this._editDoc, "selectionchange", function(e) { if (that.focussed) { var e = that._ieEvent(e); if (!that._onSelectionChangeIE( new SUI.Event(this, e))) { SUI.browser.noPropagation(e); } } } ); } // IE loses its editor selection if another element in the interface // is selected. So we need to save and restore the selection when we // lose the selection and restore it later. The blur and focus events // are the obvious events to use, but that did not work out well. the // IE specific events onbeforedeactivate and beforeactivate events // seem to do the job well. // Place to store the IE bookmark this.bmk = null; this.tmpBmk = null; // Store the current selection SUI.browser.addEventListener(this._editDoc.body, "beforedeactivate", function(e) { var e = that._ieEvent(e); if (!e.toElement) { // If there is no toElement we're moving out of this // document's body (i.e. to the userinterface). Now we // can set the focussed flag to false so that // our onselectionchange will not be triggered. // Note IE 9 uses the onselectionchange handling of real // browsers. if (SUI.browser.version < 9) { that.focussed = false; } // We want to save the bookmark when we're moving the // focus out of the body, but after IE 9 the toElement // was broken. Now we store it and let iframe.onblur take // it over. that.tmpBmk = that._ieSaveBookmark(); } } ); // Restore the current selection SUI.browser.addEventListener(this._editDoc.body, "beforeactivate", function() { that.focussed = true; that._ieRestoreBookmark(that.bmk); that.bmk = null; } ); if (!this._editDoc.body.innerHTML) { this._editDoc.body.innerHTML = "<p></p>"; } }, /** * WebKit specific initialization for the contenteditable iframe */ _initWebKit: function() { if (!this._editDoc.body.innerHTML) { this.setValue("<p><br></p>"); } }, /** * Call host's onContexMenu and prevent the default context menu */ _onContextMenu: function(e) { this.callListener("onContextMenu", SUI.browser.getX(e.event), SUI.browser.getY(e.event)); if (SUI.browser.isIE) { e.event.returnValue = false; } else { e.event.preventDefault(); } }, /** * Call the onfocus listener */ _onFocus: function(e) { if (SUI.browser.isIE) { this._editDoc.selection.createRange().select(); } this.callListener("onFocus", e); }, /** * Call host's onKeyDown */ _onKeyDown: function(e) { // TODO check paste // Patch webkit's behaviour for shift-enter (actually a Safari issue) if (SUI.browser.isWebKit && e.event.keyCode == 13 && e.event.shiftKey) { this._execCommand("insertLineBreak"); e.event.preventDefault(); } if (!SUI.browser.isIE && e.event.ctrlKey && e.event.keyCode === 86) { this._pasteData.interceptPaste(); } this.callListener("onKeyDown", e); // IE 8 has it wrong when a control (f.i. image) selection in a // contenteditable region was made and the backspace key was pressed: // it does 'browser back' instead of deleting the control. if (SUI.browser.isIE && e.event.keyCode == 8) { // if a control was selected .. if (this._editDoc.selection.type === "Control") { // ... delete it and prevent IE's default action this._execCommand("delete"); e.event.returnValue = false; } } }, /** * Some stuff going on onKeyUp. Here we can fix some stuff that WebKit * does wrong, and finish a past action. It also triggers * _onSelectionChange in certain cases. */ _onKeyUp: function(e) { // Replace div to p if WebKit inserts a div on enter if (SUI.browser.isWebKit && e.event.keyCode == 13) { this._webKitNoDivOnEnterFix(); } // TODO check paste // If we were currently pasting finish that action // this._pasteData.finishPaste(); var x = (this._editDoc.queryCommandEnabled("Undo") ? 1 : 0) + (this._editDoc.queryCommandEnabled("Redo") ? 2 : 0); if (this._undoState !== x) { this._undoState = x; this.callListener("onCommandExecuted"); } // If some sort of control key was pressed do a _onSelectionChange if (e.event.ctrlKey == true || e.event.keyCode < 48 || e.event.keyCode > 90) { this._onSelectionChange(e); } }, /** * This onload handler is called when the initial generated empty * document was inserted into the iframe. On this onload handler we * further complete the initialization process and do some more * interesting things like setting the contenteditable state, * event handlers, set (if any) content, etc. */ _onLoadIframe: function() { var that = this; // reference to the iframe's window this._editWin = this.iframe.contentWindow; // reference to the docment element in the iframe, this is important: // it is referenced all over the place this._editDoc = (this.iframe.contentWindow || this.iframe.contentDocument); if (this._editDoc.document) { this._editDoc = this._editDoc.document; } // Set charset here for IE 8 and less (see construction of iframe). if (SUI.browser.isIE && SUI.browser.version < 9) { this._editDoc.charset = "UTF-8"; } // Yes: we can go editing this._setContentEditable(true); // Browser specific initialization if (SUI.browser.isGecko) { this._initGecko(); } else if (SUI.browser.isWebKit) { this._initWebKit(); } else if (SUI.browser.isIE) { this._initIE(); } else if (!this._editDoc.body.innerHTML) { this._editDoc.body.innerHTML = "<p> </p>"; } // onMouseUp event of the editable document SUI.browser.addEventListener(this._editDoc, "mouseup", function(e) { e = that._ieEvent(e); if (!that._onMouseUp(new SUI.Event(this, e))) { SUI.browser.noPropagation(e); } }); // onFocus event of the editable document SUI.browser.addEventListener(this._editWin, "focus", function(e) { e = that._ieEvent(e); if (!that._onFocus(new SUI.Event(this, e))) { SUI.browser.noPropagation(e); } }); // onKeyDown event of the editable document SUI.browser.addEventListener(this._editDoc, "keydown", function(e) { e = that._ieEvent(e); if (!that._onKeyDown(new SUI.Event(this, e))) { SUI.browser.noPropagation(e); } }); // onKeyUp event of the editable document SUI.browser.addEventListener(this._editDoc, "keyup", function(e) { e = that._ieEvent(e); if (!that._onKeyUp(new SUI.Event(this, e))) { SUI.browser.noPropagation(e); } }); // onContextMenu event of the editable document SUI.browser.addEventListener( this._editDoc, "contextmenu", function(e) { e = that._ieEvent(e); if (!that._onContextMenu(new SUI.Event(this, e))) { SUI.browser.noPropagation(e); } }); // onPaste event of the editable body /* _onPaste: two different strategies here. IE lets you access the * clipboard so it is easy to get the data and do your own processing. * Other browser are not that convenient, here we intercept the paste * action by pasting into a hidden div. At a later moment the contents * of that div is read and inserted into the editor (finishPaste) */ if (SUI.browser.isIE) { SUI.browser.addEventListener( this._editDoc.body, "beforepaste", function(e) { e = that._ieEvent(e); if (that._ieDoBeforePaste) { if (!that._pasteData.interceptPaste()) { SUI.browser.noPropagation(e); } } } ); } else if (SUI.browser.isWebKit) { // TODO check paste SUI.browser.addEventListener(this._editDoc.body, "paste", function(e) { if (that._pasteData._div) { e = that._ieEvent(e); // check if the clipboard has HTML data if (e.clipboardData.items.length > 0) { var hasHtml = false; for (var i=0; i<e.clipboardData.types.length; i++) { if (e.clipboardData.types[i] === "text/html") { hasHtml = true; } } that._pasteData._div.innerHTML = e.clipboardData.getData( hasHtml?"text/html":"text/plain"); } SUI.browser.noPropagation(e); e.preventDefault(); that._pasteData.finishPaste(); } } ); } // Now the contenteditable is loaded an with everything in place we now // can load the stylesheets ... if (this._tmpStoreStyleSheet) { this.setCSS( this._tmpStoreStyleSheet[0], this._tmpStoreStyleSheet[1], this._tmpStoreStyleSheet[2]); } // ... and content into the control if (this._tmpStore) { this.setValue(this._tmpStore); } else { this.callListener("onLoad"); } this._loaded = true; }, /** * On mouse-up: good chance that we've changed the selection */ _onMouseUp: function(e) { this._onSelectionChange(e); }, /** * Try of we can detect if the cursor or selection moved to another * node in the DOM tree */ _onSelectionChange: function(e) { if (SUI.browser.isIE && SUI.browser.version < 9) { // IE has its own onselectionchange return; } else { var sel = this._getSelection(); var elem = this._getParentElementSelection(sel); if (this._lastSel.cllps !== null && sel.isCollapsed == this._lastSel.cllps) { // if not first iteration (!== null) and collapsed state // is not changed then check if current node selection is // changed or if we're in the next iteration if (this._lastSel.cnt == 1 || elem != this._lastSel.elem) { if (elem != this._lastSel.elem) { this._lastSel.cnt = 0; } this._lastSel.elem = elem; this.callListener("onSelectionChange"); } this._lastSel.cnt++; } else { // Collapsed state changed or first iteration this._lastSel.cnt = 0; this._lastSel.elem = elem; this.callListener("onSelectionChange"); } this._lastSel.cllps = sel.isCollapsed; } }, /** * Don't try of we can detect if the cursor or selection moved to another * node in the DOM tree: IE does it for us. This is now only for IE 8 and * below. */ _onSelectionChangeIE: function(e) { this.callListener("onSelectionChange"); }, /** * A browser independent implementation of outerHTML */ _outerHTML: function(n) { if (SUI.browser.isIE) { return n.outerHTML; } else { var n2 = document.createElement("DIV"); n2.appendChild(n.cloneNode(true)); var r = n2.innerHTML; return r; } }, /** * Select the word a the current range, but only if the range is * collapsed. This is a creates a range and is not yet added to * a selection. */ _rangeSelectWord: function(r) { if (SUI.browser.isIE) { // only try to select a word if the range is collapsed if (r.text != "") { return; } // extend the range if (r.expand("word")) { if (r.text == "") { return; } // remove a trailing space from the selection, a common // nuisance if (r.text.indexOf(" ") != -1) { r.moveEnd("character", -1); } } } else { // only try to select a word if the range is collapsed if (!r.collapsed) { return; } var so = r.startOffset; // extend the range to the whitespace before the marker try { for (var i=so; i>=0; i--) { r.setStart(r.startContainer, i); var c = r.toString(); if (c.match(/\s/)) { r.setStart(r.startContainer, i+1); break; } } } catch(e){} // extend the range to the whitespace after the marker try { for (var i=so; i<100; i++) { r.setEnd(r.startContainer, i); var c = r.toString(); c = ""+c[c.length-1]; if (c.match(/\s/)) { r.setEnd(r.startContainer, i-1); break; } } } catch(e){} // remove a trailing space from the selection, a common nuisance var rs = r.toString(); try { if (rs.length > 0 && rs[rs.length-1].match(/\s/)) { r.setEnd(r.startContainer, r.startOffset+rs.length-1); } } catch(e){} } }, /** * Replace a node, use execCommand inserthtml if possible */ _replaceNode: function(oldn, newn) { if (SUI.browser.isIE) { // does not work in IE, so use DOM and break the undo stack oldn.parentNode.replaceChild(newn, oldn); } else { // select the node ... var r = this._getCurrentRange(); r.selectNode(oldn); var s = this._getSelection(); s.removeAllRanges(); s.addRange(r); // ... and replace it this._execCommand("delete"); this._execCommand("inserthtml", this._outerHTML(newn)); } }, /** * Select the word a the current cursor position, but only if the range is * collapsed. It is a non-IE method but it is the way IE usually behaves, * so we don't need it for that. */ _selectWord: function() { if (SUI.browser.isIE) { return; } var s = this._getSelection(); var r = this._getCurrentRange(s); if (r.collapsed) { this._rangeSelectWord(r); if (r.collapsed) { return; } } s.removeAllRanges(); s.addRange(r); }, /** * Set contentEditable property of the body or otherwise sepecified * elements */ _setContentEditable: function(editable) { var that = this; // Clear all contenteditables. for (var i=0; i<this._editableElements.length; i++) { this._editableElements[i].contentEditable = false; } this._editableElements = []; this._focussedElement = null; // If we need to set it. if (editable) { // Loop through all the ids of the elements that need to be // contentedtiable ... for (var i=0; i<this._editableElementIds.length; i++) { // ... get that element and ... var e = this._editDoc.getElementById( this._editableElementIds[i]); // ... if found set it and add the element to the internal list. if (e) { this._editableElements.push(e); e.contentEditable = true; if (SUI.browser.isIE) { SUI.browser.addEventListener(e, "focus", function(e) { that._focussedElement = this; }); } } } // If there are no elements marked for contenteditable ... if (this._editableElements.length == 0) { // ... create our own marker. this._editableElements.push(this._editDoc.body); this._editDoc.body.contentEditable = true; } } }, /** * Fix images in WebKit: let the user select images by clicking on them */ _webKitImgFix: function(elem) { if (SUI.browser.isWebKit) { // if it is WebKit get all the images and add onclick handlers var imgs = this._editDoc.getElementsByTagName("IMG"); for (var i=0; i<imgs.length; i++) { // it might be added before, so try to remove handler first SUI.browser.removeEventListener(imgs[i], "click", this._webKitImgFixOnClick); // add the onclick handler for images in WebKit SUI.browser.addEventListener(imgs[i], "click", this._webKitImgFixOnClick); } } }, /** * Onclick handler for WebKit images. WebKit fails to select image when * user clicks it */ _webKitImgFixOnClick: function(e) { var r = this.ownerDocument.createRange(); r.selectNode(this); var s = this.ownerDocument.getSelection(); s.removeAllRanges(); s.addRange(r); }, /** * Fix undesired behavior in WebKit: it inserts a DIV on an Enter in * certain contexts (f.i. after a header or if there is no initial * paragraph) */ _webKitNoDivOnEnterFix: function() { // what's the selections parent? var n = this.getSelectedElement(); if (n.tagName == "DIV") { // it is a div: replace it with a p var p = this._editDoc.createElement("P"); p.innerHTML = n.innerHTML; n.parentNode.insertBefore(p, n); n.parentNode.removeChild(n); // set the cursor at the beginning of the new p var range = this._editDoc.createRange(); range.selectNodeContents(p); range.collapse(false); var s = this._getSelection(); s.removeAllRanges(); s.addRange(range); } } });