001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (C) Alkacon Software (http://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software, please see the
018 * company website: http://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: http://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.gwt.client.ui.input.tinymce;
029
030import org.opencms.gwt.client.I_CmsHasInit;
031import org.opencms.gwt.client.ui.I_CmsAutoHider;
032import org.opencms.gwt.client.ui.input.CmsTextBox;
033import org.opencms.gwt.client.ui.input.I_CmsFormWidget;
034import org.opencms.gwt.client.ui.input.form.CmsWidgetFactoryRegistry;
035import org.opencms.gwt.client.ui.input.form.I_CmsFormWidgetFactory;
036import org.opencms.util.CmsStringUtil;
037
038import java.util.Map;
039
040import com.google.common.base.Objects;
041import com.google.common.base.Optional;
042import com.google.gwt.core.client.JavaScriptObject;
043import com.google.gwt.core.client.Scheduler;
044import com.google.gwt.core.client.Scheduler.ScheduledCommand;
045import com.google.gwt.dom.client.Document;
046import com.google.gwt.dom.client.Element;
047import com.google.gwt.dom.client.EventTarget;
048import com.google.gwt.dom.client.NativeEvent;
049import com.google.gwt.event.dom.client.DomEvent;
050import com.google.gwt.event.logical.shared.HasResizeHandlers;
051import com.google.gwt.event.logical.shared.HasValueChangeHandlers;
052import com.google.gwt.event.logical.shared.ResizeEvent;
053import com.google.gwt.event.logical.shared.ResizeHandler;
054import com.google.gwt.event.logical.shared.ValueChangeEvent;
055import com.google.gwt.event.logical.shared.ValueChangeHandler;
056import com.google.gwt.event.shared.HandlerRegistration;
057import com.google.gwt.user.client.DOM;
058import com.google.gwt.user.client.Event;
059import com.google.gwt.user.client.Event.NativePreviewEvent;
060import com.google.gwt.user.client.Event.NativePreviewHandler;
061import com.google.gwt.user.client.Timer;
062import com.google.gwt.user.client.ui.FlowPanel;
063
064/**
065 * This class is used to start TinyMCE for editing the content of an element.<p>
066 *
067 * After constructing the instance, the actual editor is opened using the init() method, and destroyed with the close()
068 * method. While the editor is opened, the edited contents can be accessed using the methods of the HasValue interface.
069 */
070public final class CmsTinyMCEWidget extends FlowPanel
071implements I_CmsFormWidget, HasResizeHandlers, I_CmsHasInit, HasValueChangeHandlers<String> {
072
073    /** Use as option to disallow any HTML or formatting the content. */
074    public static final String NO_HTML_EDIT = "no_html_edit";
075
076    /** The widget type id.*/
077    public static final String WIDGET_TYPE = "wysiwyg";
078
079    /** Counts currently attached widget instances, for use in registering / deregistering preview event listeners. */
080    static int attachCount;
081
082    /** The minimum editor height. */
083    private static final int MIN_EDITOR_HEIGHT = 70;
084
085    /** The preview handler registration. */
086    private static HandlerRegistration previewRegistration;
087
088    /** The current content. */
089    protected String m_currentContent;
090
091    /** The TinyMCE editor instance. */
092    protected JavaScriptObject m_editor;
093
094    /** The DOM ID of the editable element. */
095    protected String m_id;
096
097    /** The original HTML content of the editable element. */
098    protected String m_originalContent;
099
100    /** The maximal width of the widget. */
101    protected int m_width;
102
103    /** The editor height to set. */
104    int m_editorHeight;
105
106    /** The element to store the widget content in. */
107    private Element m_contentElement;
108
109    /** Flag controlling whether the widget is enabled. */
110    private boolean m_enabled = true;
111
112    /** Indicates the value has been set from external, not from within the widget. */
113    private boolean m_externalValueChange;
114
115    /** Indicating if the widget has been attached yet. */
116    private boolean m_hasBeenAttached;
117
118    /** Flag indicating the editor has been initialized. */
119    private boolean m_initialized;
120
121    /** The editor options. */
122    private Object m_options;
123
124    /** The previous value. */
125    private String m_previousValue;
126
127    /**
128     * Creates a new instance with the given TinyMCE options. Use this constructor for form based editing.<p>
129     *
130     * @param options the tinyMCE editor options to extend the default settings
131     */
132    public CmsTinyMCEWidget(Object options) {
133
134        // super(element);
135        m_originalContent = "";
136        m_options = options;
137        // using a child DIV as content element
138        m_contentElement = getElement().appendChild(DOM.createDiv());
139    }
140
141    /**
142     * Creates a new instance based on configuration data from the server.<p>
143     *
144     * @param config the configuration data
145     */
146    public CmsTinyMCEWidget(String config) {
147
148        this(CmsTinyMCEHelper.generateOptionsForTiny(config));
149    }
150
151    /**
152     * Initializes this class.<p>
153     */
154    public static void initClass() {
155
156        // registers a factory for creating new instances of this widget
157        CmsWidgetFactoryRegistry.instance().registerFactory(WIDGET_TYPE, new I_CmsFormWidgetFactory() {
158
159            /**
160             * @see org.opencms.gwt.client.ui.input.form.I_CmsFormWidgetFactory#createWidget(java.util.Map, com.google.common.base.Optional)
161             */
162            public I_CmsFormWidget createWidget(Map<String, String> widgetParams, Optional<String> defaultValue) {
163
164                String cfg = widgetParams.get("v");
165                if (CmsStringUtil.isEmptyOrWhitespaceOnly(cfg)) {
166                    // something went wrong, fall back to text box
167                    return new CmsTextBox();
168                } else {
169                    return new CmsTinyMCEWidget(decode(cfg));
170                }
171            }
172
173            private String decode(String string) {
174
175                StringBuffer result = new StringBuffer();
176                for (String num : string.split(",")) {
177                    result.append((char)(Integer.parseInt(num)));
178                }
179                return result.toString();
180            }
181        });
182    }
183
184    /**
185     * Decrements the attach count, removes preview event handler when we go from 1 to 0.<p>
186     */
187    private static void decrementAttached() {
188
189        attachCount -= 1;
190        if ((attachCount < 1) && (previewRegistration != null)) {
191            previewRegistration.removeHandler();
192            previewRegistration = null;
193        }
194
195    }
196
197    /**
198     * Increments the attach count, installs preview event handler when we go from 0 to 1.<p>
199     */
200    private static void incrementAttached() {
201
202        attachCount += 1;
203        if (attachCount == 1) {
204            // Prevent events on TinyMCE popups from being cancelled by the PopupPanel containing the property dialog
205
206            previewRegistration = Event.addNativePreviewHandler(new NativePreviewHandler() {
207
208                public void onPreviewNativeEvent(NativePreviewEvent pEvent) {
209
210                    Event event = Event.as(pEvent.getNativeEvent());
211                    EventTarget target = event.getEventTarget();
212                    if (Element.is(target)) {
213                        Element elem = Element.as(target);
214                        while (elem != null) {
215                            if (elem.getClassName().contains("mce-floatpanel")) {
216                                pEvent.consume();
217                                return;
218                            }
219                            elem = elem.getParentElement();
220                        }
221
222                    }
223
224                }
225            });
226
227        }
228    }
229
230    /**
231     * @see com.google.gwt.event.logical.shared.HasResizeHandlers#addResizeHandler(com.google.gwt.event.logical.shared.ResizeHandler)
232     */
233    public HandlerRegistration addResizeHandler(ResizeHandler handler) {
234
235        return addHandler(handler, ResizeEvent.getType());
236    }
237
238    /**
239     * @see com.google.gwt.event.logical.shared.HasValueChangeHandlers#addValueChangeHandler(com.google.gwt.event.logical.shared.ValueChangeHandler)
240     */
241    @Override
242    public HandlerRegistration addValueChangeHandler(ValueChangeHandler<String> handler) {
243
244        return addHandler(handler, ValueChangeEvent.getType());
245    }
246
247    /**
248     * @see org.opencms.gwt.client.ui.input.I_CmsFormWidget#getApparentValue()
249     */
250    public String getApparentValue() {
251
252        return getFormValueAsString();
253    }
254
255    /**
256     * @see org.opencms.gwt.client.ui.input.I_CmsFormWidget#getFieldType()
257     */
258    public FieldType getFieldType() {
259
260        return FieldType.STRING;
261    }
262
263    /**
264     * @see org.opencms.gwt.client.ui.input.I_CmsFormWidget#getFormValue()
265     */
266    public Object getFormValue() {
267
268        return getFormValueAsString();
269    }
270
271    /**
272     * @see org.opencms.gwt.client.ui.input.I_CmsFormWidget#getFormValueAsString()
273     */
274    public String getFormValueAsString() {
275
276        return getValue();
277
278    }
279
280    /**
281     * Gets the main editable element.<p>
282     *
283     * @return the editable element
284     */
285    public Element getMainElement() {
286
287        return m_contentElement;
288    }
289
290    /**
291     * Gets the value.<p>
292     *
293     * @return the value
294     */
295    public String getValue() {
296
297        if (m_editor != null) {
298            return getContent().trim();
299        }
300        return m_originalContent.trim();
301    }
302
303    /**
304     * @see org.opencms.gwt.client.ui.input.I_CmsFormWidget#isEnabled()
305     */
306    public boolean isEnabled() {
307
308        return m_enabled;
309    }
310
311    /**
312     * @see org.opencms.gwt.client.ui.input.I_CmsFormWidget#reset()
313     */
314    public void reset() {
315
316        setFormValueAsString("");
317    }
318
319    /**
320     * @see org.opencms.gwt.client.ui.input.I_CmsFormWidget#setAutoHideParent(org.opencms.gwt.client.ui.I_CmsAutoHider)
321     */
322    public void setAutoHideParent(I_CmsAutoHider autoHideParent) {
323
324        // not supported
325    }
326
327    /**
328     * @see org.opencms.gwt.client.ui.input.I_CmsFormWidget#setEnabled(boolean)
329     *
330     * Partial support: This only works before the actual TinyMCE instance is loaded.
331     */
332    public void setEnabled(boolean enabled) {
333
334        m_enabled = enabled;
335    }
336
337    /**
338     * @see org.opencms.gwt.client.ui.input.I_CmsFormWidget#setErrorMessage(java.lang.String)
339     */
340    public void setErrorMessage(String errorMessage) {
341
342        // not supported
343    }
344
345    /**
346     * @see org.opencms.gwt.client.ui.input.I_CmsFormWidget#setFormValueAsString(java.lang.String)
347     */
348    public void setFormValueAsString(String value) {
349
350        setValue(value, false);
351    }
352
353    /**
354     * Sets the value.<p>
355     *
356     * @param value the value
357     * @param fireEvents true if value change event should be fired
358     */
359    public void setValue(String value, boolean fireEvents) {
360
361        if (value != null) {
362            value = value.trim();
363        }
364        if (!Objects.equal(value, getValue())) {
365            setPreviousValue(value);
366            if (m_editor == null) {
367                // editor has not been initialized yet
368                m_originalContent = value;
369            } else {
370                m_externalValueChange = true;
371                setContent(value);
372            }
373            if (fireEvents) {
374                fireValueChange(true);
375            }
376        }
377
378    }
379
380    /**
381     * Checks whether the necessary Javascript libraries are available by accessing them.
382     */
383    protected native void checkLibraries() /*-{
384                // fail early if tinymce is not available
385                var w = $wnd;
386                var init = w.tinyMCE.init;
387    }-*/;
388
389    /**
390     * Gives an element an id if it doesn't already have an id, and then returns the element's id.<p>
391     *
392     * @param element the element for which we want to add the id
393     *
394     * @return the id
395     */
396    protected String ensureId(Element element) {
397
398        String id = element.getId();
399        if ((id == null) || "".equals(id)) {
400            id = Document.get().createUniqueId();
401            element.setId(id);
402        }
403        return id;
404    }
405
406    /**
407     * Fires a change event.<p>
408     *
409     * @param force true if the event should be fired even if the value does not differ from the previous one
410     */
411    protected void fireValueChange(boolean force) {
412
413        String currentValue = getValue();
414        if (force || !currentValue.equals(m_previousValue)) {
415            m_previousValue = currentValue;
416            ValueChangeEvent.fire(this, currentValue);
417        }
418
419    }
420
421    /**
422     * Returns the editor parent element.<p>
423     *
424     * @return the editor parent element
425     */
426    protected Element getEditorParentElement() {
427
428        String parentId = m_id + "_parent";
429        Element result = getElementById(parentId);
430        return result;
431    }
432
433    /**
434     * Gets an element by its id.<p>
435     *
436     * @param id the id
437     * @return the element with the given id
438     */
439    protected native Element getElementById(String id) /*-{
440                return $doc.getElementById(id);
441    }-*/;
442
443    /**
444     * Gets the toolbar element.<p>
445     *
446     * @return the toolbar element
447     */
448    protected Element getToolbarElement() {
449
450        String toolbarId = m_id + "_external";
451        Element result = getElementById(toolbarId);
452        return result;
453    }
454
455    /**
456     * @see com.google.gwt.user.client.ui.Widget#onDetach()
457     */
458    @Override
459    protected void onDetach() {
460
461        try {
462            detachEditor();
463        } catch (Throwable t) {
464            // may happen in rare cases, can be ignored
465        }
466        super.onDetach();
467    }
468
469    /**
470     * @see com.google.gwt.user.client.ui.Widget#onLoad()
471     */
472    @Override
473    protected void onLoad() {
474
475        incrementAttached();
476
477        if (!m_hasBeenAttached) {
478            m_hasBeenAttached = true;
479            Scheduler.get().scheduleDeferred(new ScheduledCommand() {
480
481                @SuppressWarnings("synthetic-access")
482                public void execute() {
483
484                    if (isAttached()) {
485                        m_editorHeight = calculateEditorHeight();
486                        m_id = ensureId(getMainElement());
487                        m_width = calculateWidth();
488                        checkLibraries();
489                        initNative(!m_enabled);
490                    } else {
491                        resetAtachedFlag();
492                    }
493                }
494            });
495        }
496    }
497
498    /**
499     * @see com.google.gwt.user.client.ui.Widget#onUnload()
500     */
501    @Override
502    protected void onUnload() {
503
504        decrementAttached();
505    }
506
507    /**
508     * Propagates the a focus event.<p>
509     */
510    protected void propagateFocusEvent() {
511
512        NativeEvent nativeEvent = Document.get().createFocusEvent();
513        DomEvent.fireNativeEvent(nativeEvent, this, getElement());
514    }
515
516    /**
517     * Propagates a native mouse event.<p>
518     *
519     * @param eventType the mouse event type
520     * @param eventSource the event source
521     */
522    protected native void propagateMouseEvent(String eventType, Element eventSource) /*-{
523                var doc = $wnd.document;
524                var event;
525                if (doc.createEvent) {
526                        event = doc.createEvent("MouseEvents");
527                        event.initEvent(eventType, true, true);
528                        eventSource.dispatchEvent(event);
529                } else {
530                        eventSource.fireEvent("on" + eventType);
531                }
532    }-*/;
533
534    /**
535     * Sets focus to the editor. Use only when in line editing.<p>
536     */
537    protected native void refocusInlineEditor() /*-{
538                var elem = $wnd.document
539                                .getElementById(this.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_id);
540                elem.blur();
541                elem.focus();
542    }-*/;
543
544    /**
545     * Removes the editor instance.<p>
546     */
547    protected native void removeEditor() /*-{
548                var editor = this.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_editor;
549                editor.remove();
550    }-*/;
551
552    /**
553     * Schedules to reset the focus to the main element.<p>
554     */
555    protected void scheduleRefocus() {
556
557        // this needs to be delayed a bit, otherwise the toolbar is not rendered properly
558        Timer focusTimer = new Timer() {
559
560            @Override
561            public void run() {
562
563                refocusInlineEditor();
564            }
565        };
566        focusTimer.schedule(150);
567    }
568
569    /**
570     * Sets the main content of the element which is inline editable.<p>
571     *
572     * @param html the new content html
573     */
574    protected native void setMainElementContent(String html) /*-{
575                var instance = this;
576                var elementId = instance.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_id;
577                var mainElement = $wnd.document.getElementById(elementId);
578                mainElement.innerHTML = html;
579    }-*/;
580
581    /**
582     * Sets the previous value.<p>
583     *
584     * @param previousValue the previous value to set
585     */
586    protected void setPreviousValue(String previousValue) {
587
588        m_previousValue = previousValue;
589    }
590
591    /**
592     * Calculates the needed editor height.<p>
593     *
594     * @return the calculated editor height
595     */
596    int calculateEditorHeight() {
597
598        int result = getElement().getOffsetHeight() + 30;
599        return result > MIN_EDITOR_HEIGHT ? result : MIN_EDITOR_HEIGHT;
600    }
601
602    /**
603     * Calculates the widget width.<p>
604     *
605     * @return the widget width
606     */
607    int calculateWidth() {
608
609        return getElement().getOffsetWidth() - 2;
610    }
611
612    /**
613     * Initializes the TinyMCE instance.
614     *
615     * @param readonly if true, initialize TinyMCE in readonly mode
616     */
617    native void initNative(boolean readonly) /*-{
618
619                function merge() {
620                        var result = {}, length = arguments.length;
621                        for (i = 0; i < length; i++) {
622                                for (key in arguments[i]) {
623                                        if (arguments[i].hasOwnProperty(key)) {
624                                                result[key] = arguments[i][key];
625                                        }
626                                }
627                        }
628                        return result;
629                }
630
631                var self = this;
632                var needsRefocus = false;
633                var elementId = self.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_id;
634                var mainElement = $wnd.document.getElementById(elementId);
635                var editorHeight = self.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_editorHeight
636                                + "px";
637
638                var fireChange = function() {
639                        self.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::fireChangeFromNative()();
640                };
641                var options = this.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_options;
642                if (options != null && options.editorHeight) {
643                        editorHeight = options.editorHeight;
644                        delete options.editorHeight;
645                }
646                // default options:
647                var defaults = {
648                        elements : elementId,
649                        relative_urls : false,
650                        remove_script_host : false,
651                        entity_encoding : "raw",
652                        skin_variant : 'ocms',
653                        mode : "exact",
654                        theme : "silver",
655                        plugins : "autolink lists pagebreak table save hr image link emoticons spellchecker insertdatetime preview media searchreplace print paste directionality noneditable visualchars nonbreaking template wordcount advlist",
656                        paste_as_text : true,
657                        menubar : false,
658                };
659
660                self.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_currentContent = self.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_originalContent;
661                defaults.min_height = 100;
662                defaults.max_height = editorHeight;
663                defaults.width = '100%';
664                defaults.resize = 'both';
665
666                // extend the defaults with any given options
667                if (options != null) {
668                        defaults = merge(defaults, options);
669                }
670                defaults.plugins = "autoresize " + defaults.plugins;
671                // add the setup function
672                defaults.setup = function(ed) {
673                        self.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_editor = ed;
674                        ed.on('change', fireChange);
675                        ed.on('KeyDown', fireChange);
676                        ed
677                                        .on(
678                                                        'LoadContent',
679                                                        function() {
680
681                                                                // firing resize event on resize of the editor iframe
682                                                                ed.dom
683                                                                                .bind(
684                                                                                                ed.getWin(),
685                                                                                                'resize',
686                                                                                                function() {
687                                                                                                        self.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::fireResizeEvent()();
688                                                                                                });
689                                                                var content = self.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_originalContent;
690                                                                if (content != null) {
691                                                                        ed.setContent(content);
692                                                                }
693                                                                self.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_initialized = true;
694                                                        });
695
696                };
697                // initialize tinyMCE
698                if (readonly) {
699                        defaults.readonly = 1;
700                }
701                $wnd.tinymce.init(defaults);
702    }-*/;
703
704    /**
705     * Resets the attached flag.<p>
706     */
707    void resetAtachedFlag() {
708
709        m_hasBeenAttached = false;
710    }
711
712    /**
713     * Removes the editor.<p>
714     */
715    private native void detachEditor() /*-{
716
717                var ed = this.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_editor;
718                if (ed != null) {
719                        ed.remove();
720                }
721                // in IE somehow the whole document will be selected, empty the selection to resolve that
722                if ($wnd.document.selection != null) {
723                        $wnd.document.selection.empty();
724                }
725    }-*/;
726
727    /**
728     * Used to fire the value changed event from native code.<p>
729     */
730    private void fireChangeFromNative() {
731
732        // skip firing the change event, if the external flag is set
733        //        String message = "fireChangeFromNative\n";
734        //        message += "init: " + m_initialized + "\n";
735        //        message += "external: " + m_externalValueChange + "\n";
736        //        CmsDebugLog.consoleLog(message);
737
738        if (m_initialized && !m_externalValueChange) {
739            Scheduler.get().scheduleDeferred(new ScheduledCommand() {
740
741                public void execute() {
742
743                    try {
744                        CmsTinyMCEWidget.this.fireValueChange(false);
745                    } catch (Throwable t) {
746                        // this may happen when returning from full screen mode, nothing to be done
747                    }
748                }
749            });
750        }
751        // reset the external flag
752        m_externalValueChange = false;
753    }
754
755    /**
756     * Fires the resize event.<p>
757     */
758    private void fireResizeEvent() {
759
760        ResizeEvent.fire(this, getOffsetWidth(), getOffsetHeight());
761    }
762
763    /**
764     * Returns the editor content.<p>
765     *
766     * @return the editor content
767     */
768    private native String getContent() /*-{
769                var editor = this.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_editor;
770                return editor.getContent();
771    }-*/;
772
773    /**
774     * Sets the content of the TinyMCE editor.<p>
775     *
776     * @param newContent the new content
777     */
778    private native void setContent(String newContent) /*-{
779                var editor = this.@org.opencms.gwt.client.ui.input.tinymce.CmsTinyMCEWidget::m_editor;
780                editor.setContent(newContent);
781    }-*/;
782
783    /**
784     * Sets the editor status to enabled/disabled.<p>
785     *
786     * Warning: This only works before the TinyMCE editor has actually been initialized
787     * @param editor the editor instance
788     * @param enabled true if editor should be enabled
789     */
790    private native void setEnabled(JavaScriptObject editor, boolean enabled) /*-{
791                editor.getBody().setAttribute('contenteditable', enabled);
792    }-*/;
793
794}