1 //<script type="text/javascript">
5 * Copyright(c) 2006-2007, Ext JS, LLC.
8 * http://www.extjs.com/license
14 * Default CSS appears to render it as fixed text by default (should really be Sans-Serif)
15 * - IE ? - no idea how much works there.
23 * @class Ext.form.HtmlEditor
24 * @extends Ext.form.Field
25 * Provides a lightweight HTML Editor component.
26 * WARNING - THIS CURRENTlY ONLY WORKS ON FIREFOX - USE FCKeditor for a cross platform version
28 * <br><br><b>Note: The focus/blur and validation marking functionality inherited from Ext.form.Field is NOT
29 * supported by this editor.</b><br/><br/>
30 * An Editor is a sensitive component that can't be used in all spots standard fields can be used. Putting an Editor within
31 * any element that has display set to 'none' can cause problems in Safari and Firefox.<br/><br/>
33 Roo.form.HtmlEditor = Roo.extend(Roo.form.Field, {
35 * @cfg {Array} toolbars Array of toolbars. - defaults to just the Standard one
39 * @cfg {String} createLinkText The default text for the create link prompt
41 createLinkText : 'Please enter the URL for the link:',
43 * @cfg {String} defaultLinkValue The default value for the create link prompt (defaults to http:/ /)
45 defaultLinkValue : 'http:/'+'/',
48 * @cfg {String} resizable 's' or 'se' or 'e' - wrapps the element in a
53 * @cfg {Number} height (in pixels)
57 * @cfg {Number} width (in pixels)
64 validationEvent : false,
68 sourceEditMode : false,
69 onFocus : Roo.emptyFn,
73 defaultAutoCreate : { // modified by initCompnoent..
75 style:"width:500px;height:300px;",
80 initComponent : function(){
84 * Fires when the editor is fully initialized (including the iframe)
85 * @param {HtmlEditor} this
90 * Fires when the editor is first receives the focus. Any insertion must wait
91 * until after this event.
92 * @param {HtmlEditor} this
97 * Fires before the textarea is updated with content from the editor iframe. Return false
99 * @param {HtmlEditor} this
100 * @param {String} html
105 * Fires before the iframe editor is updated with content from the textarea. Return false
106 * to cancel the push.
107 * @param {HtmlEditor} this
108 * @param {String} html
113 * Fires when the textarea is updated with content from the editor iframe.
114 * @param {HtmlEditor} this
115 * @param {String} html
120 * Fires when the iframe editor is updated with content from the textarea.
121 * @param {HtmlEditor} this
122 * @param {String} html
126 * @event editmodechange
127 * Fires when the editor switches edit modes
128 * @param {HtmlEditor} this
129 * @param {Boolean} sourceEdit True if source edit, false if standard editing.
131 editmodechange: true,
134 * Fires when on any editor (mouse up/down cursor movement etc.) - used for toolbar hooks.
135 * @param {HtmlEditor} this
139 this.defaultAutoCreate = {
141 style:'width: ' + this.width + 'px;height: ' + this.height + 'px;',
147 * Protected method that will not generally be called directly. It
148 * is called when the editor creates its toolbar. Override this method if you need to
149 * add custom toolbar buttons.
150 * @param {HtmlEditor} editor
152 createToolbar : function(editor){
153 if (!editor.toolbars || !editor.toolbars.length) {
154 editor.toolbars = [ new Roo.form.HtmlEditor.ToolbarStandard() ]; // can be empty?
157 for (var i =0 ; i < editor.toolbars.length;i++) {
158 editor.toolbars[i] = Roo.factory(editor.toolbars[i], Roo.form.HtmlEditor);
159 editor.toolbars[i].init(editor);
166 * Protected method that will not generally be called directly. It
167 * is called when the editor initializes the iframe with HTML contents. Override this method if you
168 * want to change the initialization markup of the iframe (e.g. to add stylesheets).
170 getDocMarkup : function(){
171 return '<html><head><style type="text/css">body{border:0;margin:0;padding:3px;height:98%;cursor:text;}</style></head><body></body></html>';
175 onRender : function(ct, position){
176 Roo.form.HtmlEditor.superclass.onRender.call(this, ct, position);
177 this.el.dom.style.border = '0 none';
178 this.el.dom.setAttribute('tabIndex', -1);
179 this.el.addClass('x-hidden');
180 if(Roo.isIE){ // fix IE 1px bogus margin
181 this.el.applyStyles('margin-top:-1px;margin-bottom:-1px;')
183 this.wrap = this.el.wrap({
184 cls:'x-html-editor-wrap', cn:{cls:'x-html-editor-tb'}
187 if (this.resizable) {
188 this.resizeEl = new Roo.Resizable(this.wrap, {
191 minHeight : this.height,
193 handles : this.resizable,
199 this.frameId = Roo.id();
200 this.createToolbar(this);
207 var iframe = this.wrap.createChild({
212 'src' : Roo.SSL_SECURE_URL ? Roo.SSL_SECURE_URL : "javascript:false"
215 // console.log(iframe);
216 //this.wrap.dom.appendChild(iframe);
218 this.iframe = iframe.dom;
222 this.doc.designMode = 'on';
225 this.doc.write(this.getDocMarkup());
229 var task = { // must defer to wait for browser to be ready
231 //console.log("run task?" + this.doc.readyState);
233 if(this.doc.body || this.doc.readyState == 'complete'){
235 this.doc.designMode="on";
239 Roo.TaskMgr.stop(task);
240 this.initEditor.defer(10, this);
247 Roo.TaskMgr.start(task);
250 this.setSize(this.el.getSize());
255 onResize : function(w, h){
256 Roo.form.HtmlEditor.superclass.onResize.apply(this, arguments);
257 if(this.el && this.iframe){
258 if(typeof w == 'number'){
259 var aw = w - this.wrap.getFrameWidth('lr');
260 this.el.setWidth(this.adjustWidth('textarea', aw));
261 this.iframe.style.width = aw + 'px';
263 if(typeof h == 'number'){
265 for (var i =0; i < this.toolbars.length;i++) {
266 // fixme - ask toolbars for heights?
267 tbh += this.toolbars[i].tb.el.getHeight();
273 var ah = h - this.wrap.getFrameWidth('tb') - tbh;// this.tb.el.getHeight();
274 this.el.setHeight(this.adjustWidth('textarea', ah));
275 this.iframe.style.height = ah + 'px';
277 (this.doc.body || this.doc.documentElement).style.height = (ah - (this.iframePad*2)) + 'px';
284 * Toggles the editor between standard and source edit mode.
285 * @param {Boolean} sourceEdit (optional) True for source edit, false for standard
287 toggleSourceEdit : function(sourceEditMode){
289 this.sourceEditMode = sourceEditMode === true;
291 if(this.sourceEditMode){
294 this.iframe.className = 'x-hidden';
295 this.el.removeClass('x-hidden');
296 this.el.dom.removeAttribute('tabIndex');
301 this.iframe.className = '';
302 this.el.addClass('x-hidden');
303 this.el.dom.setAttribute('tabIndex', -1);
306 this.setSize(this.wrap.getSize());
307 this.fireEvent('editmodechange', this, this.sourceEditMode);
310 // private used internally
311 createLink : function(){
312 var url = prompt(this.createLinkText, this.defaultLinkValue);
313 if(url && url != 'http:/'+'/'){
314 this.relayCmd('createlink', url);
318 // private (for BoxComponent)
319 adjustSize : Roo.BoxComponent.prototype.adjustSize,
321 // private (for BoxComponent)
322 getResizeEl : function(){
326 // private (for BoxComponent)
327 getPositionEl : function(){
332 initEvents : function(){
333 this.originalValue = this.getValue();
337 * Overridden and disabled. The editor element does not support standard valid/invalid marking. @hide
340 markInvalid : Roo.emptyFn,
342 * Overridden and disabled. The editor element does not support standard valid/invalid marking. @hide
345 clearInvalid : Roo.emptyFn,
347 setValue : function(v){
348 Roo.form.HtmlEditor.superclass.setValue.call(this, v);
353 * Protected method that will not generally be called directly. If you need/want
354 * custom HTML cleanup, this is the method you should override.
355 * @param {String} html The HTML to be cleaned
356 * return {String} The cleaned HTML
358 cleanHtml : function(html){
361 if(Roo.isSafari){ // strip safari nonsense
362 html = html.replace(/\sclass="(?:Apple-style-span|khtml-block-placeholder)"/gi, '');
365 if(html == ' '){
372 * Protected method that will not generally be called directly. Syncs the contents
373 * of the editor iframe with the textarea.
375 syncValue : function(){
376 if(this.initialized){
377 var bd = (this.doc.body || this.doc.documentElement);
379 var html = bd.innerHTML;
381 var bs = bd.getAttribute('style'); // Safari puts text-align styles on the body element!
382 var m = bs.match(/text-align:(.*?);/i);
384 html = '<div style="'+m[0]+'">' + html + '</div>';
387 html = this.cleanHtml(html);
388 if(this.fireEvent('beforesync', this, html) !== false){
389 this.el.dom.value = html;
390 this.fireEvent('sync', this, html);
396 * Protected method that will not generally be called directly. Pushes the value of the textarea
397 * into the iframe editor.
399 pushValue : function(){
400 if(this.initialized){
401 var v = this.el.dom.value;
406 if(this.fireEvent('beforepush', this, v) !== false){
407 var d = (this.doc.body || this.doc.documentElement);
410 this.el.dom.value = d.innerHTML;
411 this.fireEvent('push', this, v);
417 deferFocus : function(){
418 this.focus.defer(10, this);
423 if(this.win && !this.sourceEditMode){
430 assignDocWin: function()
432 var iframe = this.iframe;
435 this.doc = iframe.contentWindow.document;
436 this.win = iframe.contentWindow;
438 if (!Roo.get(this.frameId)) {
441 this.doc = (iframe.contentDocument || Roo.get(this.frameId).dom.document);
442 this.win = Roo.get(this.frameId).dom.contentWindow;
447 initEditor : function(){
448 //console.log("INIT EDITOR");
453 this.doc.designMode="on";
455 this.doc.write(this.getDocMarkup());
458 var dbody = (this.doc.body || this.doc.documentElement);
459 //var ss = this.el.getStyles('font-size', 'font-family', 'background-image', 'background-repeat');
460 // this copies styles from the containing element into thsi one..
461 // not sure why we need all of this..
462 var ss = this.el.getStyles('font-size', 'background-image', 'background-repeat');
463 ss['background-attachment'] = 'fixed'; // w3c
464 dbody.bgProperties = 'fixed'; // ie
465 Roo.DomHelper.applyStyles(dbody, ss);
466 Roo.EventManager.on(this.doc, {
467 'mousedown': this.onEditorEvent,
468 'dblclick': this.onEditorEvent,
469 'click': this.onEditorEvent,
470 'keyup': this.onEditorEvent,
475 Roo.EventManager.on(this.doc, 'keypress', this.mozKeyPress, this);
477 if(Roo.isIE || Roo.isSafari || Roo.isOpera){
478 Roo.EventManager.on(this.doc, 'keydown', this.fixKeys, this);
480 this.initialized = true;
482 this.fireEvent('initialize', this);
487 onDestroy : function(){
493 for (var i =0; i < this.toolbars.length;i++) {
494 // fixme - ask toolbars for heights?
495 this.toolbars[i].onDestroy();
498 this.wrap.dom.innerHTML = '';
504 onFirstFocus : function(){
509 this.activated = true;
510 for (var i =0; i < this.toolbars.length;i++) {
511 this.toolbars[i].onFirstFocus();
514 if(Roo.isGecko){ // prevent silly gecko errors
516 var s = this.win.getSelection();
517 if(!s.focusNode || s.focusNode.nodeType != 3){
518 var r = s.getRangeAt(0);
519 r.selectNodeContents((this.doc.body || this.doc.documentElement));
524 this.execCmd('useCSS', true);
525 this.execCmd('styleWithCSS', false);
528 this.fireEvent('activate', this);
532 adjustFont: function(btn){
533 var adjust = btn.cmd == 'increasefontsize' ? 1 : -1;
534 //if(Roo.isSafari){ // safari
537 var v = parseInt(this.doc.queryCommandValue('FontSize')|| 3, 10);
538 if(Roo.isSafari){ // safari
539 var sm = { 10 : 1, 13: 2, 16:3, 18:4, 24: 5, 32:6, 48: 7 };
540 v = (v < 10) ? 10 : v;
541 v = (v > 48) ? 48 : v;
542 v = typeof(sm[v]) == 'undefined' ? 1 : sm[v];
547 v = Math.max(1, v+adjust);
549 this.execCmd('FontSize', v );
552 onEditorEvent : function(e){
553 this.fireEvent('editorevent', this, e);
554 // this.updateToolbar();
558 insertTag : function(tg)
560 // could be a bit smarter... -> wrap the current selected tRoo..
562 this.execCmd("formatblock", tg);
566 insertText : function(txt)
570 range = this.createRange();
571 range.deleteContents();
572 //alert(Sender.getAttribute('label'));
574 range.insertNode(this.doc.createTextNode(txt));
578 relayBtnCmd : function(btn){
579 this.relayCmd(btn.cmd);
583 * Executes a Midas editor command on the editor document and performs necessary focus and
584 * toolbar updates. <b>This should only be called after the editor is initialized.</b>
585 * @param {String} cmd The Midas command
586 * @param {String/Boolean} value (optional) The value to pass to the command (defaults to null)
588 relayCmd : function(cmd, value){
590 this.execCmd(cmd, value);
591 this.fireEvent('editorevent', this);
592 //this.updateToolbar();
597 * Executes a Midas editor command directly on the editor document.
598 * For visual commands, you should use {@link #relayCmd} instead.
599 * <b>This should only be called after the editor is initialized.</b>
600 * @param {String} cmd The Midas command
601 * @param {String/Boolean} value (optional) The value to pass to the command (defaults to null)
603 execCmd : function(cmd, value){
604 this.doc.execCommand(cmd, false, value === undefined ? null : value);
610 * Inserts the passed text at the current cursor position. Note: the editor must be initialized and activated
612 * @param {String} text
614 insertAtCursor : function(text){
620 var r = this.doc.selection.createRange();
627 }else if(Roo.isGecko || Roo.isOpera || Roo.isSafari){
629 this.execCmd('InsertHTML', text);
634 mozKeyPress : function(e){
636 var c = e.getCharCode(), cmd;
639 c = String.fromCharCode(c).toLowerCase();
650 this.cleanUpPaste.defer(100, this);
666 fixKeys : function(){ // load time branching for fastest keydown performance
669 var k = e.getKey(), r;
672 r = this.doc.selection.createRange();
675 r.pasteHTML('    ');
682 r = this.doc.selection.createRange();
684 var target = r.parentElement();
685 if(!target || target.tagName.toLowerCase() != 'li'){
687 r.pasteHTML('<br />');
693 if (String.fromCharCode(k).toLowerCase() == 'v') { // paste
694 this.cleanUpPaste.defer(100, this);
700 }else if(Roo.isOpera){
706 this.execCmd('InsertHTML','    ');
709 if (String.fromCharCode(k).toLowerCase() == 'v') { // paste
710 this.cleanUpPaste.defer(100, this);
715 }else if(Roo.isSafari){
721 this.execCmd('InsertText','\t');
725 if (String.fromCharCode(k).toLowerCase() == 'v') { // paste
726 this.cleanUpPaste.defer(100, this);
734 getAllAncestors: function()
736 var p = this.getSelectedNode();
739 a.push(p); // push blank onto stack..
740 p = this.getParentElement();
744 while (p && (p.nodeType == 1) && (p.tagName.toLowerCase() != 'body')) {
748 a.push(this.doc.body);
755 getSelection : function()
758 return Roo.isIE ? this.doc.selection : this.win.getSelection();
761 getSelectedNode: function()
763 // this may only work on Gecko!!!
765 // should we cache this!!!!
770 var range = this.createRange(this.getSelection());
773 var parent = range.parentElement();
775 var testRange = range.duplicate();
776 testRange.moveToElementText(parent);
777 if (testRange.inRange(range)) {
780 if ((parent.nodeType != 1) || (parent.tagName.toLowerCase() == 'body')) {
783 parent = parent.parentElement;
789 var ar = range.endContainer.childNodes;
791 ar = range.commonAncestorContainer.childNodes;
795 var other_nodes = [];
796 var has_other_nodes = false;
797 for (var i=0;i<ar.length;i++) {
798 if ((ar[i].nodeType == 3) && (!ar[i].data.length)) { // empty text ?
801 // fullly contained node.
803 if (this.rangeIntersectsNode(range,ar[i]) && this.rangeCompareNode(range,ar[i]) == 3) {
808 // probably selected..
809 if ((ar[i].nodeType == 1) && this.rangeIntersectsNode(range,ar[i]) && (this.rangeCompareNode(range,ar[i]) > 0)) {
810 other_nodes.push(ar[i]);
813 if (!this.rangeIntersectsNode(range,ar[i])|| (this.rangeCompareNode(range,ar[i]) == 0)) {
818 has_other_nodes = true;
820 if (!nodes.length && other_nodes.length) {
823 if (has_other_nodes || !nodes.length || (nodes.length > 1)) {
829 createRange: function(sel)
831 // this has strange effects when using with
832 // top toolbar - not sure if it's a great idea.
833 //this.editor.contentWindow.focus();
834 if (typeof sel != "undefined") {
836 return sel.getRangeAt ? sel.getRangeAt(0) : sel.createRange();
838 return this.doc.createRange();
841 return this.doc.createRange();
844 getParentElement: function()
848 var sel = Roo.isIE ? this.doc.selection : this.win.getSelection();
850 var range = this.createRange(sel);
853 var p = range.commonAncestorContainer;
854 while (p.nodeType == 3) { // text node
866 // BC Hacks - cause I cant work out what i was trying to do..
867 rangeIntersectsNode : function(range, node)
869 var nodeRange = node.ownerDocument.createRange();
871 nodeRange.selectNode(node);
874 nodeRange.selectNodeContents(node);
877 return range.compareBoundaryPoints(Range.END_TO_START, nodeRange) == -1 &&
878 range.compareBoundaryPoints(Range.START_TO_END, nodeRange) == 1;
880 rangeCompareNode : function(range, node) {
881 var nodeRange = node.ownerDocument.createRange();
883 nodeRange.selectNode(node);
885 nodeRange.selectNodeContents(node);
887 var nodeIsBefore = range.compareBoundaryPoints(Range.START_TO_START, nodeRange) == 1;
888 var nodeIsAfter = range.compareBoundaryPoints(Range.END_TO_END, nodeRange) == -1;
890 if (nodeIsBefore && !nodeIsAfter)
892 if (!nodeIsBefore && nodeIsAfter)
894 if (nodeIsBefore && nodeIsAfter)
900 // private? - in a new class?
901 cleanUpPaste : function()
903 // cleans up the whole document..
904 // console.log('cleanuppaste');
905 this.cleanUpChildren(this.doc.body);
909 cleanUpChildren : function (n)
911 if (!n.childNodes.length) {
914 for (var i = n.childNodes.length-1; i > -1 ; i--) {
915 this.cleanUpChild(n.childNodes[i]);
922 cleanUpChild : function (node)
925 if (node.nodeName == "#text") {
926 // clean up silly Windows -- stuff?
929 if (node.nodeName == "#comment") {
930 node.parentNode.removeChild(node);
931 // clean up silly Windows -- stuff?
935 if (Roo.form.HtmlEditor.black.indexOf(node.tagName.toLowerCase()) > -1) {
937 node.parentNode.removeChild(node);
941 if (!node.attributes || !node.attributes.length) {
942 this.cleanUpChildren(node);
946 function cleanAttr(n,v)
949 if (v.match(/^\./) || v.match(/^\//)) {
952 if (v.match(/^(http|https):\/\//) || v.match(/^mailto:/)) {
955 Roo.log("(REMOVE)"+ node.tagName +'.' + n + '=' + v);
956 node.removeAttribute(n);
960 function cleanStyle(n,v)
962 if (v.match(/expression/)) { //XSS?? should we even bother..
963 node.removeAttribute(n);
968 var parts = v.split(/;/);
969 Roo.each(parts, function(p) {
970 p = p.replace(/\s+/g,'');
974 var l = p.split(':').shift().replace(/\s+/g,'');
976 if (Roo.form.HtmlEditor.cwhite.indexOf(l) < 0) {
977 Roo.log('(REMOVE)' + node.tagName +'.' + n + ':'+l + '=' + v);
978 node.removeAttribute(n);
987 for (var i = node.attributes.length-1; i > -1 ; i--) {
988 var a = node.attributes[i];
990 if (Roo.form.HtmlEditor.ablack.indexOf(a.name.toLowerCase()) > -1) {
991 node.removeAttribute(a.name);
994 if (Roo.form.HtmlEditor.aclean.indexOf(a.name.toLowerCase()) > -1) {
995 cleanAttr(a.name,a.value); // fixme..
998 if (a.name == 'style') {
999 cleanStyle(a.name,a.value);
1001 /// clean up MS crap..
1002 if (a.name == 'class') {
1003 if (a.value.match(/^Mso/)) {
1004 node.className = '';
1014 this.cleanUpChildren(node);
1020 // hide stuff that is not compatible
1038 * @cfg {String} fieldClass @hide
1041 * @cfg {String} focusClass @hide
1044 * @cfg {String} autoCreate @hide
1047 * @cfg {String} inputType @hide
1050 * @cfg {String} invalidClass @hide
1053 * @cfg {String} invalidText @hide
1056 * @cfg {String} msgFx @hide
1059 * @cfg {String} validateOnBlur @hide
1063 Roo.form.HtmlEditor.white = [
1064 'area', 'br', 'img', 'input', 'hr', 'wbr',
1066 'address', 'blockquote', 'center', 'dd', 'dir', 'div',
1067 'dl', 'dt', 'h1', 'h2', 'h3', 'h4',
1068 'h5', 'h6', 'hr', 'isindex', 'listing', 'marquee',
1069 'menu', 'multicol', 'ol', 'p', 'plaintext', 'pre',
1070 'table', 'ul', 'xmp',
1072 'caption', 'col', 'colgroup', 'tbody', 'td', 'tfoot', 'th',
1075 'dir', 'menu', 'ol', 'ul', 'dl',
1081 Roo.form.HtmlEditor.black = [
1082 // 'embed', 'object', // enable - backend responsiblity to clean thiese
1084 'base', 'basefont', 'bgsound', 'blink', 'body',
1085 'frame', 'frameset', 'head', 'html', 'ilayer',
1086 'iframe', 'layer', 'link', 'meta', 'object',
1087 'script', 'style' ,'title', 'xml' // clean later..
1089 Roo.form.HtmlEditor.clean = [
1090 'script', 'style', 'title', 'xml'
1095 Roo.form.HtmlEditor.ablack = [
1099 Roo.form.HtmlEditor.aclean = [
1100 'action', 'background', 'codebase', 'dynsrc', 'href', 'lowsrc'
1104 Roo.form.HtmlEditor.pwhite= [
1105 'http', 'https', 'mailto'
1108 Roo.form.HtmlEditor.cwhite= [