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:/'+'/',
52 validationEvent : false,
56 sourceEditMode : false,
57 onFocus : Roo.emptyFn,
62 style:"width:500px;height:300px;",
67 initComponent : function(){
71 * Fires when the editor is fully initialized (including the iframe)
72 * @param {HtmlEditor} this
77 * Fires when the editor is first receives the focus. Any insertion must wait
78 * until after this event.
79 * @param {HtmlEditor} this
84 * Fires before the textarea is updated with content from the editor iframe. Return false
86 * @param {HtmlEditor} this
87 * @param {String} html
92 * Fires before the iframe editor is updated with content from the textarea. Return false
94 * @param {HtmlEditor} this
95 * @param {String} html
100 * Fires when the textarea is updated with content from the editor iframe.
101 * @param {HtmlEditor} this
102 * @param {String} html
107 * Fires when the iframe editor is updated with content from the textarea.
108 * @param {HtmlEditor} this
109 * @param {String} html
113 * @event editmodechange
114 * Fires when the editor switches edit modes
115 * @param {HtmlEditor} this
116 * @param {Boolean} sourceEdit True if source edit, false if standard editing.
118 editmodechange: true,
121 * Fires when on any editor (mouse up/down cursor movement etc.) - used for toolbar hooks.
122 * @param {HtmlEditor} this
129 * Protected method that will not generally be called directly. It
130 * is called when the editor creates its toolbar. Override this method if you need to
131 * add custom toolbar buttons.
132 * @param {HtmlEditor} editor
134 createToolbar : function(editor){
135 if (!editor.toolbars || !editor.toolbars.length) {
136 editor.toolbars = [ new Roo.form.HtmlEditor.ToolbarStandard() ]; // can be empty?
139 for (var i =0 ; i < editor.toolbars.length;i++) {
140 editor.toolbars[i] = Roo.factory(editor.toolbars[i], Roo.form.HtmlEditor);
141 editor.toolbars[i].init(editor);
148 * Protected method that will not generally be called directly. It
149 * is called when the editor initializes the iframe with HTML contents. Override this method if you
150 * want to change the initialization markup of the iframe (e.g. to add stylesheets).
152 getDocMarkup : function(){
153 return '<html><head><style type="text/css">body{border:0;margin:0;padding:3px;height:98%;cursor:text;}</style></head><body></body></html>';
157 onRender : function(ct, position){
158 Roo.form.HtmlEditor.superclass.onRender.call(this, ct, position);
159 this.el.dom.style.border = '0 none';
160 this.el.dom.setAttribute('tabIndex', -1);
161 this.el.addClass('x-hidden');
162 if(Roo.isIE){ // fix IE 1px bogus margin
163 this.el.applyStyles('margin-top:-1px;margin-bottom:-1px;')
165 this.wrap = this.el.wrap({
166 cls:'x-html-editor-wrap', cn:{cls:'x-html-editor-tb'}
169 this.frameId = Roo.id();
170 this.createToolbar(this);
177 var iframe = this.wrap.createChild({
182 'src' : Roo.SSL_SECURE_URL ? Roo.SSL_SECURE_URL : "javascript:false"
185 // console.log(iframe);
186 //this.wrap.dom.appendChild(iframe);
188 this.iframe = iframe.dom;
192 this.doc.designMode = 'on';
195 this.doc.write(this.getDocMarkup());
199 var task = { // must defer to wait for browser to be ready
201 //console.log("run task?" + this.doc.readyState);
203 if(this.doc.body || this.doc.readyState == 'complete'){
205 this.doc.designMode="on";
209 Roo.TaskMgr.stop(task);
210 this.initEditor.defer(10, this);
217 Roo.TaskMgr.start(task);
220 this.setSize(this.el.getSize());
225 onResize : function(w, h){
226 Roo.form.HtmlEditor.superclass.onResize.apply(this, arguments);
227 if(this.el && this.iframe){
228 if(typeof w == 'number'){
229 var aw = w - this.wrap.getFrameWidth('lr');
230 this.el.setWidth(this.adjustWidth('textarea', aw));
231 this.iframe.style.width = aw + 'px';
233 if(typeof h == 'number'){
235 for (var i =0; i < this.toolbars.length;i++) {
236 // fixme - ask toolbars for heights?
237 tbh += this.toolbars[i].tb.el.getHeight();
243 var ah = h - this.wrap.getFrameWidth('tb') - tbh;// this.tb.el.getHeight();
244 this.el.setHeight(this.adjustWidth('textarea', ah));
245 this.iframe.style.height = ah + 'px';
247 (this.doc.body || this.doc.documentElement).style.height = (ah - (this.iframePad*2)) + 'px';
254 * Toggles the editor between standard and source edit mode.
255 * @param {Boolean} sourceEdit (optional) True for source edit, false for standard
257 toggleSourceEdit : function(sourceEditMode){
259 this.sourceEditMode = sourceEditMode === true;
261 if(this.sourceEditMode){
264 this.iframe.className = 'x-hidden';
265 this.el.removeClass('x-hidden');
266 this.el.dom.removeAttribute('tabIndex');
271 this.iframe.className = '';
272 this.el.addClass('x-hidden');
273 this.el.dom.setAttribute('tabIndex', -1);
276 this.setSize(this.wrap.getSize());
277 this.fireEvent('editmodechange', this, this.sourceEditMode);
280 // private used internally
281 createLink : function(){
282 var url = prompt(this.createLinkText, this.defaultLinkValue);
283 if(url && url != 'http:/'+'/'){
284 this.relayCmd('createlink', url);
288 // private (for BoxComponent)
289 adjustSize : Roo.BoxComponent.prototype.adjustSize,
291 // private (for BoxComponent)
292 getResizeEl : function(){
296 // private (for BoxComponent)
297 getPositionEl : function(){
302 initEvents : function(){
303 this.originalValue = this.getValue();
307 * Overridden and disabled. The editor element does not support standard valid/invalid marking. @hide
310 markInvalid : Roo.emptyFn,
312 * Overridden and disabled. The editor element does not support standard valid/invalid marking. @hide
315 clearInvalid : Roo.emptyFn,
317 setValue : function(v){
318 Roo.form.HtmlEditor.superclass.setValue.call(this, v);
323 * Protected method that will not generally be called directly. If you need/want
324 * custom HTML cleanup, this is the method you should override.
325 * @param {String} html The HTML to be cleaned
326 * return {String} The cleaned HTML
328 cleanHtml : function(html){
331 if(Roo.isSafari){ // strip safari nonsense
332 html = html.replace(/\sclass="(?:Apple-style-span|khtml-block-placeholder)"/gi, '');
335 if(html == ' '){
342 * Protected method that will not generally be called directly. Syncs the contents
343 * of the editor iframe with the textarea.
345 syncValue : function(){
346 if(this.initialized){
347 var bd = (this.doc.body || this.doc.documentElement);
349 var html = bd.innerHTML;
351 var bs = bd.getAttribute('style'); // Safari puts text-align styles on the body element!
352 var m = bs.match(/text-align:(.*?);/i);
354 html = '<div style="'+m[0]+'">' + html + '</div>';
357 html = this.cleanHtml(html);
358 if(this.fireEvent('beforesync', this, html) !== false){
359 this.el.dom.value = html;
360 this.fireEvent('sync', this, html);
366 * Protected method that will not generally be called directly. Pushes the value of the textarea
367 * into the iframe editor.
369 pushValue : function(){
370 if(this.initialized){
371 var v = this.el.dom.value;
376 if(this.fireEvent('beforepush', this, v) !== false){
377 var d = (this.doc.body || this.doc.documentElement);
380 this.el.dom.value = d.innerHTML;
381 this.fireEvent('push', this, v);
387 deferFocus : function(){
388 this.focus.defer(10, this);
393 if(this.win && !this.sourceEditMode){
400 assignDocWin: function()
402 var iframe = this.iframe;
405 this.doc = iframe.contentWindow.document;
406 this.win = iframe.contentWindow;
408 if (!Roo.get(this.frameId)) {
411 this.doc = (iframe.contentDocument || Roo.get(this.frameId).dom.document);
412 this.win = Roo.get(this.frameId).dom.contentWindow;
417 initEditor : function(){
418 //console.log("INIT EDITOR");
423 this.doc.designMode="on";
425 this.doc.write(this.getDocMarkup());
428 var dbody = (this.doc.body || this.doc.documentElement);
429 //var ss = this.el.getStyles('font-size', 'font-family', 'background-image', 'background-repeat');
430 // this copies styles from the containing element into thsi one..
431 // not sure why we need all of this..
432 var ss = this.el.getStyles('font-size', 'background-image', 'background-repeat');
433 ss['background-attachment'] = 'fixed'; // w3c
434 dbody.bgProperties = 'fixed'; // ie
435 Roo.DomHelper.applyStyles(dbody, ss);
436 Roo.EventManager.on(this.doc, {
437 'mousedown': this.onEditorEvent,
438 'dblclick': this.onEditorEvent,
439 'click': this.onEditorEvent,
440 'keyup': this.onEditorEvent,
445 Roo.EventManager.on(this.doc, 'keypress', this.mozKeyPress, this);
447 if(Roo.isIE || Roo.isSafari || Roo.isOpera){
448 Roo.EventManager.on(this.doc, 'keydown', this.fixKeys, this);
450 this.initialized = true;
452 this.fireEvent('initialize', this);
457 onDestroy : function(){
463 for (var i =0; i < this.toolbars.length;i++) {
464 // fixme - ask toolbars for heights?
465 this.toolbars[i].onDestroy();
468 this.wrap.dom.innerHTML = '';
474 onFirstFocus : function(){
479 this.activated = true;
480 for (var i =0; i < this.toolbars.length;i++) {
481 this.toolbars[i].onFirstFocus();
484 if(Roo.isGecko){ // prevent silly gecko errors
486 var s = this.win.getSelection();
487 if(!s.focusNode || s.focusNode.nodeType != 3){
488 var r = s.getRangeAt(0);
489 r.selectNodeContents((this.doc.body || this.doc.documentElement));
494 this.execCmd('useCSS', true);
495 this.execCmd('styleWithCSS', false);
498 this.fireEvent('activate', this);
502 adjustFont: function(btn){
503 var adjust = btn.cmd == 'increasefontsize' ? 1 : -1;
504 //if(Roo.isSafari){ // safari
507 var v = parseInt(this.doc.queryCommandValue('FontSize')|| 3, 10);
508 if(Roo.isSafari){ // safari
509 var sm = { 10 : 1, 13: 2, 16:3, 18:4, 24: 5, 32:6, 48: 7 };
510 v = (v < 10) ? 10 : v;
511 v = (v > 48) ? 48 : v;
512 v = typeof(sm[v]) == 'undefined' ? 1 : sm[v];
517 v = Math.max(1, v+adjust);
519 this.execCmd('FontSize', v );
522 onEditorEvent : function(e){
523 this.fireEvent('editorevent', this, e);
524 // this.updateToolbar();
528 insertTag : function(tg)
530 // could be a bit smarter... -> wrap the current selected tRoo..
532 this.execCmd("formatblock", tg);
536 insertText : function(txt)
540 range = this.createRange();
541 range.deleteContents();
542 //alert(Sender.getAttribute('label'));
544 range.insertNode(this.doc.createTextNode(txt));
548 relayBtnCmd : function(btn){
549 this.relayCmd(btn.cmd);
553 * Executes a Midas editor command on the editor document and performs necessary focus and
554 * toolbar updates. <b>This should only be called after the editor is initialized.</b>
555 * @param {String} cmd The Midas command
556 * @param {String/Boolean} value (optional) The value to pass to the command (defaults to null)
558 relayCmd : function(cmd, value){
560 this.execCmd(cmd, value);
561 this.fireEvent('editorevent', this);
562 //this.updateToolbar();
567 * Executes a Midas editor command directly on the editor document.
568 * For visual commands, you should use {@link #relayCmd} instead.
569 * <b>This should only be called after the editor is initialized.</b>
570 * @param {String} cmd The Midas command
571 * @param {String/Boolean} value (optional) The value to pass to the command (defaults to null)
573 execCmd : function(cmd, value){
574 this.doc.execCommand(cmd, false, value === undefined ? null : value);
580 * Inserts the passed text at the current cursor position. Note: the editor must be initialized and activated
582 * @param {String} text
584 insertAtCursor : function(text){
590 var r = this.doc.selection.createRange();
597 }else if(Roo.isGecko || Roo.isOpera || Roo.isSafari){
599 this.execCmd('InsertHTML', text);
604 mozKeyPress : function(e){
606 var c = e.getCharCode(), cmd;
609 c = String.fromCharCode(c).toLowerCase();
620 this.cleanUpPaste.defer(100, this);
636 fixKeys : function(){ // load time branching for fastest keydown performance
639 var k = e.getKey(), r;
642 r = this.doc.selection.createRange();
645 r.pasteHTML('    ');
652 r = this.doc.selection.createRange();
654 var target = r.parentElement();
655 if(!target || target.tagName.toLowerCase() != 'li'){
657 r.pasteHTML('<br />');
663 if (String.fromCharCode(k).toLowerCase() == 'v') { // paste
664 this.cleanUpPaste.defer(100, this);
670 }else if(Roo.isOpera){
676 this.execCmd('InsertHTML','    ');
679 if (String.fromCharCode(k).toLowerCase() == 'v') { // paste
680 this.cleanUpPaste.defer(100, this);
685 }else if(Roo.isSafari){
691 this.execCmd('InsertText','\t');
695 if (String.fromCharCode(k).toLowerCase() == 'v') { // paste
696 this.cleanUpPaste.defer(100, this);
704 getAllAncestors: function()
706 var p = this.getSelectedNode();
709 a.push(p); // push blank onto stack..
710 p = this.getParentElement();
714 while (p && (p.nodeType == 1) && (p.tagName.toLowerCase() != 'body')) {
718 a.push(this.doc.body);
725 getSelection : function()
728 return Roo.isIE ? this.doc.selection : this.win.getSelection();
731 getSelectedNode: function()
733 // this may only work on Gecko!!!
735 // should we cache this!!!!
740 var range = this.createRange(this.getSelection());
743 var parent = range.parentElement();
745 var testRange = range.duplicate();
746 testRange.moveToElementText(parent);
747 if (testRange.inRange(range)) {
750 if ((parent.nodeType != 1) || (parent.tagName.toLowerCase() == 'body')) {
753 parent = parent.parentElement;
759 var ar = range.endContainer.childNodes;
761 ar = range.commonAncestorContainer.childNodes;
765 var other_nodes = [];
766 var has_other_nodes = false;
767 for (var i=0;i<ar.length;i++) {
768 if ((ar[i].nodeType == 3) && (!ar[i].data.length)) { // empty text ?
771 // fullly contained node.
773 if (this.rangeIntersectsNode(range,ar[i]) && this.rangeCompareNode(range,ar[i]) == 3) {
778 // probably selected..
779 if ((ar[i].nodeType == 1) && this.rangeIntersectsNode(range,ar[i]) && (this.rangeCompareNode(range,ar[i]) > 0)) {
780 other_nodes.push(ar[i]);
783 if (!this.rangeIntersectsNode(range,ar[i])|| (this.rangeCompareNode(range,ar[i]) == 0)) {
788 has_other_nodes = true;
790 if (!nodes.length && other_nodes.length) {
793 if (has_other_nodes || !nodes.length || (nodes.length > 1)) {
799 createRange: function(sel)
801 // this has strange effects when using with
802 // top toolbar - not sure if it's a great idea.
803 //this.editor.contentWindow.focus();
804 if (typeof sel != "undefined") {
806 return sel.getRangeAt ? sel.getRangeAt(0) : sel.createRange();
808 return this.doc.createRange();
811 return this.doc.createRange();
814 getParentElement: function()
818 var sel = Roo.isIE ? this.doc.selection : this.win.getSelection();
820 var range = this.createRange(sel);
823 var p = range.commonAncestorContainer;
824 while (p.nodeType == 3) { // text node
836 // BC Hacks - cause I cant work out what i was trying to do..
837 rangeIntersectsNode : function(range, node)
839 var nodeRange = node.ownerDocument.createRange();
841 nodeRange.selectNode(node);
844 nodeRange.selectNodeContents(node);
847 return range.compareBoundaryPoints(Range.END_TO_START, nodeRange) == -1 &&
848 range.compareBoundaryPoints(Range.START_TO_END, nodeRange) == 1;
850 rangeCompareNode : function(range, node) {
851 var nodeRange = node.ownerDocument.createRange();
853 nodeRange.selectNode(node);
855 nodeRange.selectNodeContents(node);
857 var nodeIsBefore = range.compareBoundaryPoints(Range.START_TO_START, nodeRange) == 1;
858 var nodeIsAfter = range.compareBoundaryPoints(Range.END_TO_END, nodeRange) == -1;
860 if (nodeIsBefore && !nodeIsAfter)
862 if (!nodeIsBefore && nodeIsAfter)
864 if (nodeIsBefore && nodeIsAfter)
870 // private? - in a new class?
871 cleanUpPaste : function()
873 // cleans up the whole document..
874 // console.log('cleanuppaste');
875 this.cleanUpChildren(this.doc.body);
879 cleanUpChildren : function (n)
881 if (!n.childNodes.length) {
884 for (var i = n.childNodes.length-1; i > -1 ; i--) {
885 this.cleanUpChild(n.childNodes[i]);
892 cleanUpChild : function (node)
895 if (node.nodeName == "#text") {
896 // clean up silly Windows -- stuff?
899 if (node.nodeName == "#comment") {
900 node.parentNode.removeChild(node);
901 // clean up silly Windows -- stuff?
905 if (Roo.form.HtmlEditor.black.indexOf(node.tagName.toLowerCase()) > -1) {
907 node.parentNode.removeChild(node);
911 if (!node.attributes || !node.attributes.length) {
912 this.cleanUpChildren(node);
916 function cleanAttr(n,v)
919 if (v.match(/^\./) || v.match(/^\//)) {
922 if (v.match(/^(http|https):\/\//) || v.match(/^mailto:/)) {
925 Roo.log("(REMOVE)"+ node.tagName +'.' + n + '=' + v);
926 node.removeAttribute(n);
930 function cleanStyle(n,v)
932 if (v.match(/expression/)) { //XSS?? should we even bother..
933 node.removeAttribute(n);
938 var parts = v.split(/;/);
939 Roo.each(parts, function(p) {
940 p = p.replace(/\s+/g,'');
944 var l = p.split(':').shift().replace(/\s+/g,'');
946 if (Roo.form.HtmlEditor.cwhite.indexOf(l) < 0) {
947 Roo.log('(REMOVE)' + node.tagName +'.' + n + ':'+l + '=' + v);
948 node.removeAttribute(n);
957 for (var i = node.attributes.length-1; i > -1 ; i--) {
958 var a = node.attributes[i];
960 if (Roo.form.HtmlEditor.ablack.indexOf(a.name.toLowerCase()) > -1) {
961 node.removeAttribute(a.name);
964 if (Roo.form.HtmlEditor.aclean.indexOf(a.name.toLowerCase()) > -1) {
965 cleanAttr(a.name,a.value); // fixme..
968 if (a.name == 'style') {
969 cleanStyle(a.name,a.value);
971 /// clean up MS crap..
972 if (a.name == 'class') {
973 if (a.value.match(/^Mso/)) {
984 this.cleanUpChildren(node);
990 // hide stuff that is not compatible
1008 * @cfg {String} fieldClass @hide
1011 * @cfg {String} focusClass @hide
1014 * @cfg {String} autoCreate @hide
1017 * @cfg {String} inputType @hide
1020 * @cfg {String} invalidClass @hide
1023 * @cfg {String} invalidText @hide
1026 * @cfg {String} msgFx @hide
1029 * @cfg {String} validateOnBlur @hide
1033 Roo.form.HtmlEditor.white = [
1034 'area', 'br', 'img', 'input', 'hr', 'wbr',
1036 'address', 'blockquote', 'center', 'dd', 'dir', 'div',
1037 'dl', 'dt', 'h1', 'h2', 'h3', 'h4',
1038 'h5', 'h6', 'hr', 'isindex', 'listing', 'marquee',
1039 'menu', 'multicol', 'ol', 'p', 'plaintext', 'pre',
1040 'table', 'ul', 'xmp',
1042 'caption', 'col', 'colgroup', 'tbody', 'td', 'tfoot', 'th',
1045 'dir', 'menu', 'ol', 'ul', 'dl',
1051 Roo.form.HtmlEditor.black = [
1052 // 'embed', 'object', // enable - backend responsiblity to clean thiese
1054 'base', 'basefont', 'bgsound', 'blink', 'body',
1055 'frame', 'frameset', 'head', 'html', 'ilayer',
1056 'iframe', 'layer', 'link', 'meta', 'object',
1057 'script', 'style' ,'title', 'xml' // clean later..
1059 Roo.form.HtmlEditor.clean = [
1060 'script', 'style', 'title', 'xml'
1065 Roo.form.HtmlEditor.ablack = [
1069 Roo.form.HtmlEditor.aclean = [
1070 'action', 'background', 'codebase', 'dynsrc', 'href', 'lowsrc'
1074 Roo.form.HtmlEditor.pwhite= [
1075 'http', 'https', 'mailto'
1078 Roo.form.HtmlEditor.cwhite= [