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)
61 validationEvent : false,
65 sourceEditMode : false,
66 onFocus : Roo.emptyFn,
72 style:"width:500px;height:300px;",
77 initComponent : function(){
81 * Fires when the editor is fully initialized (including the iframe)
82 * @param {HtmlEditor} this
87 * Fires when the editor is first receives the focus. Any insertion must wait
88 * until after this event.
89 * @param {HtmlEditor} this
94 * Fires before the textarea is updated with content from the editor iframe. Return false
96 * @param {HtmlEditor} this
97 * @param {String} html
102 * Fires before the iframe editor is updated with content from the textarea. Return false
103 * to cancel the push.
104 * @param {HtmlEditor} this
105 * @param {String} html
110 * Fires when the textarea is updated with content from the editor iframe.
111 * @param {HtmlEditor} this
112 * @param {String} html
117 * Fires when the iframe editor is updated with content from the textarea.
118 * @param {HtmlEditor} this
119 * @param {String} html
123 * @event editmodechange
124 * Fires when the editor switches edit modes
125 * @param {HtmlEditor} this
126 * @param {Boolean} sourceEdit True if source edit, false if standard editing.
128 editmodechange: true,
131 * Fires when on any editor (mouse up/down cursor movement etc.) - used for toolbar hooks.
132 * @param {HtmlEditor} this
136 this.defaultAutoCreate = {
138 style:'width: ' + this.width + 'px;height: ' + this.height + 'px;",
144 * Protected method that will not generally be called directly. It
145 * is called when the editor creates its toolbar. Override this method if you need to
146 * add custom toolbar buttons.
147 * @param {HtmlEditor} editor
149 createToolbar : function(editor){
150 if (!editor.toolbars || !editor.toolbars.length) {
151 editor.toolbars = [ new Roo.form.HtmlEditor.ToolbarStandard() ]; // can be empty?
154 for (var i =0 ; i < editor.toolbars.length;i++) {
155 editor.toolbars[i] = Roo.factory(editor.toolbars[i], Roo.form.HtmlEditor);
156 editor.toolbars[i].init(editor);
163 * Protected method that will not generally be called directly. It
164 * is called when the editor initializes the iframe with HTML contents. Override this method if you
165 * want to change the initialization markup of the iframe (e.g. to add stylesheets).
167 getDocMarkup : function(){
168 return '<html><head><style type="text/css">body{border:0;margin:0;padding:3px;height:98%;cursor:text;}</style></head><body></body></html>';
172 onRender : function(ct, position){
173 Roo.form.HtmlEditor.superclass.onRender.call(this, ct, position);
174 this.el.dom.style.border = '0 none';
175 this.el.dom.setAttribute('tabIndex', -1);
176 this.el.addClass('x-hidden');
177 if(Roo.isIE){ // fix IE 1px bogus margin
178 this.el.applyStyles('margin-top:-1px;margin-bottom:-1px;')
180 this.wrap = this.el.wrap({
181 cls:'x-html-editor-wrap', cn:{cls:'x-html-editor-tb'}
184 if (this.resizable) {
185 this.resizeEl = new Roo.Resizable(this.wrap, {
188 minHeight : this.height,
189 handles : this.resizable
194 this.frameId = Roo.id();
195 this.createToolbar(this);
202 var iframe = this.wrap.createChild({
207 'src' : Roo.SSL_SECURE_URL ? Roo.SSL_SECURE_URL : "javascript:false"
210 // console.log(iframe);
211 //this.wrap.dom.appendChild(iframe);
213 this.iframe = iframe.dom;
217 this.doc.designMode = 'on';
220 this.doc.write(this.getDocMarkup());
224 var task = { // must defer to wait for browser to be ready
226 //console.log("run task?" + this.doc.readyState);
228 if(this.doc.body || this.doc.readyState == 'complete'){
230 this.doc.designMode="on";
234 Roo.TaskMgr.stop(task);
235 this.initEditor.defer(10, this);
242 Roo.TaskMgr.start(task);
245 this.setSize(this.el.getSize());
250 onResize : function(w, h){
251 Roo.form.HtmlEditor.superclass.onResize.apply(this, arguments);
252 if(this.el && this.iframe){
253 if(typeof w == 'number'){
254 var aw = w - this.wrap.getFrameWidth('lr');
255 this.el.setWidth(this.adjustWidth('textarea', aw));
256 this.iframe.style.width = aw + 'px';
258 if(typeof h == 'number'){
260 for (var i =0; i < this.toolbars.length;i++) {
261 // fixme - ask toolbars for heights?
262 tbh += this.toolbars[i].tb.el.getHeight();
268 var ah = h - this.wrap.getFrameWidth('tb') - tbh;// this.tb.el.getHeight();
269 this.el.setHeight(this.adjustWidth('textarea', ah));
270 this.iframe.style.height = ah + 'px';
272 (this.doc.body || this.doc.documentElement).style.height = (ah - (this.iframePad*2)) + 'px';
279 * Toggles the editor between standard and source edit mode.
280 * @param {Boolean} sourceEdit (optional) True for source edit, false for standard
282 toggleSourceEdit : function(sourceEditMode){
284 this.sourceEditMode = sourceEditMode === true;
286 if(this.sourceEditMode){
289 this.iframe.className = 'x-hidden';
290 this.el.removeClass('x-hidden');
291 this.el.dom.removeAttribute('tabIndex');
296 this.iframe.className = '';
297 this.el.addClass('x-hidden');
298 this.el.dom.setAttribute('tabIndex', -1);
301 this.setSize(this.wrap.getSize());
302 this.fireEvent('editmodechange', this, this.sourceEditMode);
305 // private used internally
306 createLink : function(){
307 var url = prompt(this.createLinkText, this.defaultLinkValue);
308 if(url && url != 'http:/'+'/'){
309 this.relayCmd('createlink', url);
313 // private (for BoxComponent)
314 adjustSize : Roo.BoxComponent.prototype.adjustSize,
316 // private (for BoxComponent)
317 getResizeEl : function(){
321 // private (for BoxComponent)
322 getPositionEl : function(){
327 initEvents : function(){
328 this.originalValue = this.getValue();
332 * Overridden and disabled. The editor element does not support standard valid/invalid marking. @hide
335 markInvalid : Roo.emptyFn,
337 * Overridden and disabled. The editor element does not support standard valid/invalid marking. @hide
340 clearInvalid : Roo.emptyFn,
342 setValue : function(v){
343 Roo.form.HtmlEditor.superclass.setValue.call(this, v);
348 * Protected method that will not generally be called directly. If you need/want
349 * custom HTML cleanup, this is the method you should override.
350 * @param {String} html The HTML to be cleaned
351 * return {String} The cleaned HTML
353 cleanHtml : function(html){
356 if(Roo.isSafari){ // strip safari nonsense
357 html = html.replace(/\sclass="(?:Apple-style-span|khtml-block-placeholder)"/gi, '');
360 if(html == ' '){
367 * Protected method that will not generally be called directly. Syncs the contents
368 * of the editor iframe with the textarea.
370 syncValue : function(){
371 if(this.initialized){
372 var bd = (this.doc.body || this.doc.documentElement);
374 var html = bd.innerHTML;
376 var bs = bd.getAttribute('style'); // Safari puts text-align styles on the body element!
377 var m = bs.match(/text-align:(.*?);/i);
379 html = '<div style="'+m[0]+'">' + html + '</div>';
382 html = this.cleanHtml(html);
383 if(this.fireEvent('beforesync', this, html) !== false){
384 this.el.dom.value = html;
385 this.fireEvent('sync', this, html);
391 * Protected method that will not generally be called directly. Pushes the value of the textarea
392 * into the iframe editor.
394 pushValue : function(){
395 if(this.initialized){
396 var v = this.el.dom.value;
401 if(this.fireEvent('beforepush', this, v) !== false){
402 var d = (this.doc.body || this.doc.documentElement);
405 this.el.dom.value = d.innerHTML;
406 this.fireEvent('push', this, v);
412 deferFocus : function(){
413 this.focus.defer(10, this);
418 if(this.win && !this.sourceEditMode){
425 assignDocWin: function()
427 var iframe = this.iframe;
430 this.doc = iframe.contentWindow.document;
431 this.win = iframe.contentWindow;
433 if (!Roo.get(this.frameId)) {
436 this.doc = (iframe.contentDocument || Roo.get(this.frameId).dom.document);
437 this.win = Roo.get(this.frameId).dom.contentWindow;
442 initEditor : function(){
443 //console.log("INIT EDITOR");
448 this.doc.designMode="on";
450 this.doc.write(this.getDocMarkup());
453 var dbody = (this.doc.body || this.doc.documentElement);
454 //var ss = this.el.getStyles('font-size', 'font-family', 'background-image', 'background-repeat');
455 // this copies styles from the containing element into thsi one..
456 // not sure why we need all of this..
457 var ss = this.el.getStyles('font-size', 'background-image', 'background-repeat');
458 ss['background-attachment'] = 'fixed'; // w3c
459 dbody.bgProperties = 'fixed'; // ie
460 Roo.DomHelper.applyStyles(dbody, ss);
461 Roo.EventManager.on(this.doc, {
462 'mousedown': this.onEditorEvent,
463 'dblclick': this.onEditorEvent,
464 'click': this.onEditorEvent,
465 'keyup': this.onEditorEvent,
470 Roo.EventManager.on(this.doc, 'keypress', this.mozKeyPress, this);
472 if(Roo.isIE || Roo.isSafari || Roo.isOpera){
473 Roo.EventManager.on(this.doc, 'keydown', this.fixKeys, this);
475 this.initialized = true;
477 this.fireEvent('initialize', this);
482 onDestroy : function(){
488 for (var i =0; i < this.toolbars.length;i++) {
489 // fixme - ask toolbars for heights?
490 this.toolbars[i].onDestroy();
493 this.wrap.dom.innerHTML = '';
499 onFirstFocus : function(){
504 this.activated = true;
505 for (var i =0; i < this.toolbars.length;i++) {
506 this.toolbars[i].onFirstFocus();
509 if(Roo.isGecko){ // prevent silly gecko errors
511 var s = this.win.getSelection();
512 if(!s.focusNode || s.focusNode.nodeType != 3){
513 var r = s.getRangeAt(0);
514 r.selectNodeContents((this.doc.body || this.doc.documentElement));
519 this.execCmd('useCSS', true);
520 this.execCmd('styleWithCSS', false);
523 this.fireEvent('activate', this);
527 adjustFont: function(btn){
528 var adjust = btn.cmd == 'increasefontsize' ? 1 : -1;
529 //if(Roo.isSafari){ // safari
532 var v = parseInt(this.doc.queryCommandValue('FontSize')|| 3, 10);
533 if(Roo.isSafari){ // safari
534 var sm = { 10 : 1, 13: 2, 16:3, 18:4, 24: 5, 32:6, 48: 7 };
535 v = (v < 10) ? 10 : v;
536 v = (v > 48) ? 48 : v;
537 v = typeof(sm[v]) == 'undefined' ? 1 : sm[v];
542 v = Math.max(1, v+adjust);
544 this.execCmd('FontSize', v );
547 onEditorEvent : function(e){
548 this.fireEvent('editorevent', this, e);
549 // this.updateToolbar();
553 insertTag : function(tg)
555 // could be a bit smarter... -> wrap the current selected tRoo..
557 this.execCmd("formatblock", tg);
561 insertText : function(txt)
565 range = this.createRange();
566 range.deleteContents();
567 //alert(Sender.getAttribute('label'));
569 range.insertNode(this.doc.createTextNode(txt));
573 relayBtnCmd : function(btn){
574 this.relayCmd(btn.cmd);
578 * Executes a Midas editor command on the editor document and performs necessary focus and
579 * toolbar updates. <b>This should only be called after the editor is initialized.</b>
580 * @param {String} cmd The Midas command
581 * @param {String/Boolean} value (optional) The value to pass to the command (defaults to null)
583 relayCmd : function(cmd, value){
585 this.execCmd(cmd, value);
586 this.fireEvent('editorevent', this);
587 //this.updateToolbar();
592 * Executes a Midas editor command directly on the editor document.
593 * For visual commands, you should use {@link #relayCmd} instead.
594 * <b>This should only be called after the editor is initialized.</b>
595 * @param {String} cmd The Midas command
596 * @param {String/Boolean} value (optional) The value to pass to the command (defaults to null)
598 execCmd : function(cmd, value){
599 this.doc.execCommand(cmd, false, value === undefined ? null : value);
605 * Inserts the passed text at the current cursor position. Note: the editor must be initialized and activated
607 * @param {String} text
609 insertAtCursor : function(text){
615 var r = this.doc.selection.createRange();
622 }else if(Roo.isGecko || Roo.isOpera || Roo.isSafari){
624 this.execCmd('InsertHTML', text);
629 mozKeyPress : function(e){
631 var c = e.getCharCode(), cmd;
634 c = String.fromCharCode(c).toLowerCase();
645 this.cleanUpPaste.defer(100, this);
661 fixKeys : function(){ // load time branching for fastest keydown performance
664 var k = e.getKey(), r;
667 r = this.doc.selection.createRange();
670 r.pasteHTML('    ');
677 r = this.doc.selection.createRange();
679 var target = r.parentElement();
680 if(!target || target.tagName.toLowerCase() != 'li'){
682 r.pasteHTML('<br />');
688 if (String.fromCharCode(k).toLowerCase() == 'v') { // paste
689 this.cleanUpPaste.defer(100, this);
695 }else if(Roo.isOpera){
701 this.execCmd('InsertHTML','    ');
704 if (String.fromCharCode(k).toLowerCase() == 'v') { // paste
705 this.cleanUpPaste.defer(100, this);
710 }else if(Roo.isSafari){
716 this.execCmd('InsertText','\t');
720 if (String.fromCharCode(k).toLowerCase() == 'v') { // paste
721 this.cleanUpPaste.defer(100, this);
729 getAllAncestors: function()
731 var p = this.getSelectedNode();
734 a.push(p); // push blank onto stack..
735 p = this.getParentElement();
739 while (p && (p.nodeType == 1) && (p.tagName.toLowerCase() != 'body')) {
743 a.push(this.doc.body);
750 getSelection : function()
753 return Roo.isIE ? this.doc.selection : this.win.getSelection();
756 getSelectedNode: function()
758 // this may only work on Gecko!!!
760 // should we cache this!!!!
765 var range = this.createRange(this.getSelection());
768 var parent = range.parentElement();
770 var testRange = range.duplicate();
771 testRange.moveToElementText(parent);
772 if (testRange.inRange(range)) {
775 if ((parent.nodeType != 1) || (parent.tagName.toLowerCase() == 'body')) {
778 parent = parent.parentElement;
784 var ar = range.endContainer.childNodes;
786 ar = range.commonAncestorContainer.childNodes;
790 var other_nodes = [];
791 var has_other_nodes = false;
792 for (var i=0;i<ar.length;i++) {
793 if ((ar[i].nodeType == 3) && (!ar[i].data.length)) { // empty text ?
796 // fullly contained node.
798 if (this.rangeIntersectsNode(range,ar[i]) && this.rangeCompareNode(range,ar[i]) == 3) {
803 // probably selected..
804 if ((ar[i].nodeType == 1) && this.rangeIntersectsNode(range,ar[i]) && (this.rangeCompareNode(range,ar[i]) > 0)) {
805 other_nodes.push(ar[i]);
808 if (!this.rangeIntersectsNode(range,ar[i])|| (this.rangeCompareNode(range,ar[i]) == 0)) {
813 has_other_nodes = true;
815 if (!nodes.length && other_nodes.length) {
818 if (has_other_nodes || !nodes.length || (nodes.length > 1)) {
824 createRange: function(sel)
826 // this has strange effects when using with
827 // top toolbar - not sure if it's a great idea.
828 //this.editor.contentWindow.focus();
829 if (typeof sel != "undefined") {
831 return sel.getRangeAt ? sel.getRangeAt(0) : sel.createRange();
833 return this.doc.createRange();
836 return this.doc.createRange();
839 getParentElement: function()
843 var sel = Roo.isIE ? this.doc.selection : this.win.getSelection();
845 var range = this.createRange(sel);
848 var p = range.commonAncestorContainer;
849 while (p.nodeType == 3) { // text node
861 // BC Hacks - cause I cant work out what i was trying to do..
862 rangeIntersectsNode : function(range, node)
864 var nodeRange = node.ownerDocument.createRange();
866 nodeRange.selectNode(node);
869 nodeRange.selectNodeContents(node);
872 return range.compareBoundaryPoints(Range.END_TO_START, nodeRange) == -1 &&
873 range.compareBoundaryPoints(Range.START_TO_END, nodeRange) == 1;
875 rangeCompareNode : function(range, node) {
876 var nodeRange = node.ownerDocument.createRange();
878 nodeRange.selectNode(node);
880 nodeRange.selectNodeContents(node);
882 var nodeIsBefore = range.compareBoundaryPoints(Range.START_TO_START, nodeRange) == 1;
883 var nodeIsAfter = range.compareBoundaryPoints(Range.END_TO_END, nodeRange) == -1;
885 if (nodeIsBefore && !nodeIsAfter)
887 if (!nodeIsBefore && nodeIsAfter)
889 if (nodeIsBefore && nodeIsAfter)
895 // private? - in a new class?
896 cleanUpPaste : function()
898 // cleans up the whole document..
899 // console.log('cleanuppaste');
900 this.cleanUpChildren(this.doc.body);
904 cleanUpChildren : function (n)
906 if (!n.childNodes.length) {
909 for (var i = n.childNodes.length-1; i > -1 ; i--) {
910 this.cleanUpChild(n.childNodes[i]);
917 cleanUpChild : function (node)
920 if (node.nodeName == "#text") {
921 // clean up silly Windows -- stuff?
924 if (node.nodeName == "#comment") {
925 node.parentNode.removeChild(node);
926 // clean up silly Windows -- stuff?
930 if (Roo.form.HtmlEditor.black.indexOf(node.tagName.toLowerCase()) > -1) {
932 node.parentNode.removeChild(node);
936 if (!node.attributes || !node.attributes.length) {
937 this.cleanUpChildren(node);
941 function cleanAttr(n,v)
944 if (v.match(/^\./) || v.match(/^\//)) {
947 if (v.match(/^(http|https):\/\//) || v.match(/^mailto:/)) {
950 Roo.log("(REMOVE)"+ node.tagName +'.' + n + '=' + v);
951 node.removeAttribute(n);
955 function cleanStyle(n,v)
957 if (v.match(/expression/)) { //XSS?? should we even bother..
958 node.removeAttribute(n);
963 var parts = v.split(/;/);
964 Roo.each(parts, function(p) {
965 p = p.replace(/\s+/g,'');
969 var l = p.split(':').shift().replace(/\s+/g,'');
971 if (Roo.form.HtmlEditor.cwhite.indexOf(l) < 0) {
972 Roo.log('(REMOVE)' + node.tagName +'.' + n + ':'+l + '=' + v);
973 node.removeAttribute(n);
982 for (var i = node.attributes.length-1; i > -1 ; i--) {
983 var a = node.attributes[i];
985 if (Roo.form.HtmlEditor.ablack.indexOf(a.name.toLowerCase()) > -1) {
986 node.removeAttribute(a.name);
989 if (Roo.form.HtmlEditor.aclean.indexOf(a.name.toLowerCase()) > -1) {
990 cleanAttr(a.name,a.value); // fixme..
993 if (a.name == 'style') {
994 cleanStyle(a.name,a.value);
996 /// clean up MS crap..
997 if (a.name == 'class') {
998 if (a.value.match(/^Mso/)) {
1009 this.cleanUpChildren(node);
1015 // hide stuff that is not compatible
1033 * @cfg {String} fieldClass @hide
1036 * @cfg {String} focusClass @hide
1039 * @cfg {String} autoCreate @hide
1042 * @cfg {String} inputType @hide
1045 * @cfg {String} invalidClass @hide
1048 * @cfg {String} invalidText @hide
1051 * @cfg {String} msgFx @hide
1054 * @cfg {String} validateOnBlur @hide
1058 Roo.form.HtmlEditor.white = [
1059 'area', 'br', 'img', 'input', 'hr', 'wbr',
1061 'address', 'blockquote', 'center', 'dd', 'dir', 'div',
1062 'dl', 'dt', 'h1', 'h2', 'h3', 'h4',
1063 'h5', 'h6', 'hr', 'isindex', 'listing', 'marquee',
1064 'menu', 'multicol', 'ol', 'p', 'plaintext', 'pre',
1065 'table', 'ul', 'xmp',
1067 'caption', 'col', 'colgroup', 'tbody', 'td', 'tfoot', 'th',
1070 'dir', 'menu', 'ol', 'ul', 'dl',
1076 Roo.form.HtmlEditor.black = [
1077 // 'embed', 'object', // enable - backend responsiblity to clean thiese
1079 'base', 'basefont', 'bgsound', 'blink', 'body',
1080 'frame', 'frameset', 'head', 'html', 'ilayer',
1081 'iframe', 'layer', 'link', 'meta', 'object',
1082 'script', 'style' ,'title', 'xml' // clean later..
1084 Roo.form.HtmlEditor.clean = [
1085 'script', 'style', 'title', 'xml'
1090 Roo.form.HtmlEditor.ablack = [
1094 Roo.form.HtmlEditor.aclean = [
1095 'action', 'background', 'codebase', 'dynsrc', 'href', 'lowsrc'
1099 Roo.form.HtmlEditor.pwhite= [
1100 'http', 'https', 'mailto'
1103 Roo.form.HtmlEditor.cwhite= [