com.microsoft.tfs.client.common.ui.controls.generic.html.HTMLEditor.java Source code

Java tutorial

Introduction

Here is the source code for com.microsoft.tfs.client.common.ui.controls.generic.html.HTMLEditor.java

Source

// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See License.txt in the repository root.

package com.microsoft.tfs.client.common.ui.controls.generic.html;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.eclipse.core.runtime.Preferences;
import org.eclipse.jface.dialogs.IDialogConstants;
import org.eclipse.swt.SWT;
import org.eclipse.swt.SWTException;
import org.eclipse.swt.browser.Browser;
import org.eclipse.swt.browser.LocationAdapter;
import org.eclipse.swt.browser.LocationEvent;
import org.eclipse.swt.browser.LocationListener;
import org.eclipse.swt.browser.OpenWindowListener;
import org.eclipse.swt.browser.WindowEvent;
import org.eclipse.swt.custom.BusyIndicator;
import org.eclipse.swt.events.DisposeEvent;
import org.eclipse.swt.events.DisposeListener;
import org.eclipse.swt.events.KeyAdapter;
import org.eclipse.swt.events.KeyEvent;
import org.eclipse.swt.events.ModifyEvent;
import org.eclipse.swt.events.ModifyListener;
import org.eclipse.swt.events.MouseAdapter;
import org.eclipse.swt.events.MouseEvent;
import org.eclipse.swt.events.SelectionAdapter;
import org.eclipse.swt.events.SelectionEvent;
import org.eclipse.swt.graphics.FontData;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.graphics.RGB;
import org.eclipse.swt.layout.FillLayout;
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.layout.GridLayout;
import org.eclipse.swt.widgets.ColorDialog;
import org.eclipse.swt.widgets.Combo;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.CoolBar;
import org.eclipse.swt.widgets.CoolItem;
import org.eclipse.swt.widgets.Event;
import org.eclipse.swt.widgets.Label;
import org.eclipse.swt.widgets.Listener;
import org.eclipse.swt.widgets.MenuItem;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.swt.widgets.ToolBar;
import org.eclipse.swt.widgets.ToolItem;

import com.microsoft.tfs.client.common.ui.Messages;
import com.microsoft.tfs.client.common.ui.TFSCommonUIClientPlugin;
import com.microsoft.tfs.client.common.ui.browser.BrowserFacade;
import com.microsoft.tfs.client.common.ui.controls.generic.FullFeaturedBrowser;
import com.microsoft.tfs.client.common.ui.controls.generic.html.DropdownToolItemSelectionListener.MenuItemSelectedHandler;
import com.microsoft.tfs.client.common.ui.dialogs.generic.StringInputDialog;
import com.microsoft.tfs.client.common.ui.framework.WindowSystem;
import com.microsoft.tfs.client.common.ui.framework.helper.MessageBoxHelpers;
import com.microsoft.tfs.client.common.ui.framework.image.ImageHelper;
import com.microsoft.tfs.client.common.ui.framework.table.tooltip.TableTooltipLabelManager;
import com.microsoft.tfs.client.common.ui.prefs.UIPreferenceConstants;
import com.microsoft.tfs.core.util.URIUtils;
import com.microsoft.tfs.util.Check;
import com.microsoft.tfs.util.Platform;
import com.microsoft.tfs.util.listeners.SingleListenerFacade;

/**
 * <p>
 * A rich text editing control for HTML content. Only supported on Eclipse 3.5
 * and later (though this class compiles on Eclipse 3.2 and newer) where the
 * Browser hosts Internet Explorer, Mozilla, or Webkit. Use
 * {@link #isAvailable()} to test whether the control will load in the current
 * platform ({@link Browser} will load and supports the required Javascript/HTML
 * features). If the {@link #DISABLE_PROPERTY_NAME} system property is set, the
 * control always reports itself unavailable.
 * </p>
 * <h1>Asynchronous Usage Notice</h1>
 * <p>
 * Some methods on this control may only be executed <b>after</b> the browser
 * completes the load of the Javascript editor content, which can only be
 * reliably detected via the {@link EditorReadyListener} supplied at
 * construction. This behavior is dictated by the underlying {@link Browser}
 * control, which runs Javascript asynchronously. Methods which <b>cannot</b> be
 * used until the editor is ready:
 * <ul>
 * <li>{@link #getReadOnly()}</li>
 * <li>{@link #setReadOnly(boolean)}</li>
 * <li>{@link #getHTML()}</li>
 * <li>{@link #setHTML(String)}</li>
 * </ul>
 * If you call these methods before the editor is ready, they will throw
 * {@link IllegalStateException}.
 * </p>
 *
 * @threadsafety thread-compatible
 */
public class HTMLEditor extends Composite {
    /**
     * {@link HTMLEditor} is always unavailable ({@link #isAvailable()} returns
     * <code>false</code>) when this system property is set to any value.
     */
    public static final String DISABLE_PROPERTY_NAME = "com.microsoft.tfs.client.common.ui.controls.generic.html.htmleditor.disable"; //$NON-NLS-1$

    private static final Log log = LogFactory.getLog(HTMLEditor.class);

    /**
     * Minimum version of SWT required to use the HTML editor. 3.5 and later are
     * supported.
     */
    private static final int MINIMUM_SWT_VERSION = 3500;

    private static final int TOOL_TIP_SHOW_DELAY_MILLISECONDS = 1000;

    /**
     * The name of the Javascript HTML editor resource.
     */
    private static final String HTML_EDITOR_RESOURCE = "HTMLEditor.html"; //$NON-NLS-1$

    /**
     * The encoding used to read the {@link #HTML_EDITOR_RESOURCE} resource.
     */
    private static final String HTML_EDITOR_RESOURCE_ENCODING = "UTF-8"; //$NON-NLS-1$

    /**
     * The default font size (HTML size) for the font drop-down.
     */
    private static final String DEFAULT_FONT_SIZE = "2"; //$NON-NLS-1$

    /**
     * The modifier key which must be down when clicking or pressing Enter on a
     * link to open it (instead of edit it).
     */
    private static final int OPEN_LINK_MODIFIER_KEY = WindowSystem.isCurrentWindowSystem(WindowSystem.AQUA)
            ? SWT.COMMAND
            : SWT.CONTROL;

    /**
     * Matches strings like "rgb(0,0,0)", "rgb (1, 2 ,3 )"
     */
    private static final Pattern RGB_CSS_COLOR_PATTERN = Pattern.compile(
            "rgb[\\s]*\\(([^,]*?),([^,]*?),([^\\)]*?)\\)", //$NON-NLS-1$
            Pattern.CASE_INSENSITIVE);

    /**
     * Initialized by {@link #isAvailable()} to <code>true</code> if this
     * control can be used on the running platform, <code>false</code> if it
     * cannot, null if not yet tested.
     */
    private static Boolean browserAvailable;
    private static int browserAvailableForStyle;

    /**
     * The {@link FullFeaturedBrowser} used to host the JavaScript-based editor.
     */
    private final FullFeaturedBrowser browser;

    /**
     * Set to <code>true</code> when the {@link FullFeaturedBrowser} completes
     * the load of the Javascript HTML editor program. Tracked for safety, so
     * some methods can throw {@link IllegalStateException} if the editor has
     * not been loaded (instead of risking putting the native browser into a bad
     * state).
     */
    private boolean editorReady = false;

    /**
     * Editor ready listener; one is given in the constructor
     */
    private final EditorReadyListener editorReadyListener;

    /**
     * Content modification listeners.
     */
    private final SingleListenerFacade modifyListeners = new SingleListenerFacade(ModifyListener.class);

    /**
     * Provides toolbar images.
     */
    private final ImageHelper imageHelper = new ImageHelper(TFSCommonUIClientPlugin.PLUGIN_ID);

    /*
     * Tool bar items.
     */
    private final CoolBar coolBar;
    private Combo fontSizeCombo;
    private Combo fontNameCombo;
    private MenuItem leftMenuItem;
    private MenuItem centerMenuItem;
    private MenuItem rightMenuItem;
    private MenuItem justifyMenuItem;
    private ToolItem underlineButtonItem;
    private ToolItem italicButtonItem;
    private ToolItem boldButtonItem;
    private ToolItem indentButtonItem;
    private ToolItem outdentButtonItem;
    private ToolItem orderedListButtonItem;
    private ToolItem unorderedListButtonItem;
    private ToolItem linkButtonItem;

    private Shell toolTipShell;
    private Timer toolTipShellTimer;

    /*
     * Styles computed per-platform so the control matches the native HTML
     * editing experience.
     */
    private final int coolbarStyle;
    private final int toolbarStyle;
    private final int fullFeaturedBrowserStyle;

    /**
     * Gets whether {@link HTMLEditor} is availble for the running platform
     * (browser requirements are met). If this method returns <code>false</code>
     * , construction via
     * {@link #HTMLEditor(Composite, EditorReadyListener, int)} will throw.
     *
     * @return <code>true</code> if {@link HTMLEditor} is supported on the
     *         running platform, <code>false</code> if it is disabled (the
     *         {@link #DISABLE_PROPERTY_NAME} system property is set) or if
     *         minimum browser requirements are not met (see the log for
     *         details)
     * @see HTMLEditor
     * @see HTMLEditor#DISABLE_PROPERTY_NAME
     */
    public static boolean isAvailable() {
        if (System.getProperty(DISABLE_PROPERTY_NAME) != null) {
            return false;
        }

        /*
         * No synchronization here because this method is always called on the
         * UI thread.
         */
        final int browserStyle = getBrowserStyle();
        if (HTMLEditor.browserAvailable == null || browserStyle != HTMLEditor.browserAvailableForStyle) {
            HTMLEditor.browserAvailable = Boolean.valueOf(isAvailableInternal());
            HTMLEditor.browserAvailableForStyle = browserStyle;
        }

        return HTMLEditor.browserAvailable;
    }

    private static boolean isAvailableInternal() {
        /*
         * OS X's SWT native browser is bad on Carbon, avoid it.
         * https://bugs.eclipse.org/bugs/show_bug.cgi?id=230035
         */
        if (WindowSystem.isCurrentWindowSystem(WindowSystem.CARBON)) {
            HTMLEditor.log.warn("HTMLEditor does not support SWT Browser on Mac OS Carbon"); //$NON-NLS-1$
            return false;
        }

        if (SWT.getVersion() < HTMLEditor.MINIMUM_SWT_VERSION) {
            HTMLEditor.log.warn(
                    MessageFormat.format("SWT version {0} not new enough ({1} or newer required) to use HTMLEditor", //$NON-NLS-1$
                            Integer.toString(SWT.getVersion()), Integer.toString(HTMLEditor.MINIMUM_SWT_VERSION)));
            return false;
        }

        Shell shell = null;
        FullFeaturedBrowser browser = null;

        try {
            shell = new Shell();
            browser = new FullFeaturedBrowser(shell, SWT.NONE, getBrowserStyle());

            browser.setJavascriptEnabled(true);
            if (browser.getJavascriptEnabled() == false) {
                HTMLEditor.log.warn("Could not enable Javascript in SWT Browser for HTMLEditor"); //$NON-NLS-1$
                return false;
            }

            /*
             * On Windows, only IE 7 and newer will run our Javascript
             * correctly. We don't use XMLHttpRequest, but it's a good test for
             * IE 6, which doesn't have it (IE 7+ does).
             */
            if (Platform.isCurrentPlatform(Platform.WINDOWS) && browser.getBrowserType().equalsIgnoreCase("ie")) //$NON-NLS-1$
            {
                // Our Javascript needs a document to run with.
                browser.setText("<html></html>"); //$NON-NLS-1$

                final Object hasXMLHttpRequest = browser.evaluate("return ('XMLHttpRequest' in window);"); //$NON-NLS-1$

                if (hasXMLHttpRequest != null && hasXMLHttpRequest instanceof Boolean
                        && ((Boolean) hasXMLHttpRequest).booleanValue() == false) {
                    HTMLEditor.log.warn("IE major version 6 detected; this version not supported by HTMLEditor"); //$NON-NLS-1$
                    return false;
                }
            }

            // Success!
            HTMLEditor.log.info("SWT Browser successfully loaded for HTMLEditor"); //$NON-NLS-1$
            return true;
        } catch (final Throwable t) {
            HTMLEditor.log.warn("SWT Browser failed to load for HTMLEditor", t); //$NON-NLS-1$
            return false;
        } finally {
            if (browser != null) {
                browser.dispose();
            }

            if (shell != null) {
                shell.dispose();
            }
        }
    }

    private static int getBrowserStyle() {
        final Preferences preferences = TFSCommonUIClientPlugin.getDefault().getPluginPreferences();
        return preferences.getInt(UIPreferenceConstants.EMBEDDED_WEB_BROWSER_TYPE);
    }

    /**
     * Constructs an {@link HTMLEditor}. Unlike many SWT widgets,
     * {@link HTMLEditor}'s initialization is asynchronous because the native
     * web browser can run Javascript in the background. The given
     * {@link EditorReadyListener} is invoked when the control is ready. See the
     * class Javadoc for details.
     *
     * @param parent
     *        the widget's parent (must not be <code>null</code>)
     * @param editorReadyListener
     *        the listener to be notified when the HTML editor has completed
     *        loading and is ready for use (must not be <code>null</code>)
     * @param style
     *        the widget's style (the control will automatically enable
     *        {@link SWT#BORDER} on some platforms to match the appearance of
     *        native HTML editor controls, so it's best for users not to set
     *        that flag)
     * @throws IllegalStateException
     *         if the control is not available for this platform (
     *         {@link #isAvailable()} would return <code>false</code>)
     */
    public HTMLEditor(final Composite parent, final EditorReadyListener editorReadyListener, final int style) {
        super(parent, style);

        Check.notNull(parent, "parent"); //$NON-NLS-1$
        Check.notNull(editorReadyListener, "editorReadyListener"); //$NON-NLS-1$

        if (HTMLEditor.isAvailable() == false) {
            throw new IllegalStateException("SWT Browser not available or functional on this platform"); //$NON-NLS-1$
        }

        this.editorReadyListener = editorReadyListener;

        addDisposeListener(new DisposeListener() {
            @Override
            public void widgetDisposed(final DisposeEvent e) {
                imageHelper.dispose();

                hideToolTip();
            }
        });

        /*
         * Use different border styles depending on platform.
         */
        if (WindowSystem.isCurrentWindowSystem(WindowSystem.WINDOWS)
                || WindowSystem.isCurrentWindowSystem(WindowSystem.GTK)) {
            // Matches standard Eclipse look.
            coolbarStyle = SWT.FLAT;

            // On Windows only flat toolbars can be keyboard-traversed. GTK
            // looks better this way.
            toolbarStyle = SWT.FLAT;

            // Single thin line border on browser.
            fullFeaturedBrowserStyle = SWT.BORDER;
        } else {
            // TODO customize for other platforms.

            coolbarStyle = SWT.FLAT;
            toolbarStyle = SWT.FLAT;
            fullFeaturedBrowserStyle = SWT.BORDER;
        }

        /*
         * Use a grid layout, one column wide. Coolbar goes at the top, then
         * browser. Set spacing and margins to 0 for a tight fit.
         */
        final GridLayout layout = new GridLayout(1, true);
        layout.marginHeight = 0;
        layout.marginWidth = 0;
        layout.horizontalSpacing = 0;
        layout.verticalSpacing = 0;
        setLayout(layout);

        /*
         * Use a CoolBar at the top level because ToolBar on GTK cannot wrap
         * buttons, and we need wrapping or else the editor may be unusable in
         * small places (work item forms). A CoolBar will wrap its children
         * (ToolBars) automatically if they specify a minimum size.
         */

        coolBar = new CoolBar(this, coolbarStyle);
        coolBar.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
        coolBar.addListener(SWT.Resize, new Listener() {
            @Override
            public void handleEvent(final Event event) {
                /*
                 * Coolbars can resize themselves (users drag strips to new
                 * vertical places), so we must force a relayout of this control
                 * so the browser area shrinks/grows in response to the new
                 * size.
                 */
                layout();
            }
        });

        createCoolItem(coolBar, createFontToolBar(coolBar));
        createCoolItem(coolBar, createFormatToolBar(coolBar));
        createCoolItem(coolBar, createColorToolBar(coolBar));
        createCoolItem(coolBar, createAlignmentToolBar(coolBar));
        createCoolItem(coolBar, createListToolBar(coolBar));
        createCoolItem(coolBar, createIndentToolBar(coolBar));
        createCoolItem(coolBar, createLinkToolBar(coolBar));

        // Use default Browser style but style the composite for borders, etc.
        browser = new FullFeaturedBrowser(this, fullFeaturedBrowserStyle, getBrowserStyle());
        browser.setLayoutData(new GridData(GridData.FILL_BOTH));
        browser.setJavascriptEnabled(true);

        /*
         * Handle Control/Command-Click to open a link under the mouse.
         */
        browser.getBrowser().addMouseListener(new MouseAdapter() {
            @Override
            public void mouseUp(final MouseEvent e) {
                if (e.button == 1 && (e.stateMask & OPEN_LINK_MODIFIER_KEY) != 0) {
                    openLinkUnderMouseCursor();
                }
            }
        });

        /*
         * These key combinations are handled specially here, the rest are left
         * to the browser control to handle:
         *
         * - Control/Command-Enter to open a link under the selection
         */
        browser.getBrowser().addKeyListener(new KeyAdapter() {
            @Override
            public void keyPressed(final KeyEvent e) {
                if (e.keyCode == SWT.CR && (e.stateMask & OPEN_LINK_MODIFIER_KEY) != 0) {
                    openLinkUnderSelection();
                }
            }
        });

        // Loads the Javascript application.
        initializeBrowser();

        // Create the tooltip and timer to show link click help
        createLinkHelpToolTip();
    }

    /**
     * Override to compute the size with each CoolBar item on its own row. We
     * don't want the control to be sized based on the width of the entire
     * CoolBar since we expect it to collapse if needed. There are layout
     * scenarios that that will cause the HTMLEditor to layout for the full
     * width of a CoolBar and other scenarios where it won't, so we explicitly
     * collapse the CoolBar to ensure the CoolBar is not the determining factor
     * in the overall HTMLEditor control width (see pioneer bug 4349)
     */
    @Override
    public Point computeSize(final int wHint, final int hHint, final boolean flag) {
        if (wHint == -1) {
            final int[] currentIndicies = coolBar.getWrapIndices();
            final int[] allIndicies = new int[coolBar.getItemCount()];
            for (int i = 0; i < allIndicies.length; i++) {
                allIndicies[i] = i;
            }

            coolBar.setWrapIndices(allIndicies);
            final Point p = super.computeSize(wHint, hHint, flag);
            coolBar.setWrapIndices(currentIndicies);
            return p;
        } else {
            return super.computeSize(wHint, hHint, flag);
        }
    }

    private void createLinkHelpToolTip() {
        toolTipShell = new Shell(getShell(), SWT.ON_TOP | SWT.NO_FOCUS | SWT.TOOL);
        toolTipShell.setBackground(getDisplay().getSystemColor(SWT.COLOR_INFO_BACKGROUND));

        final FillLayout layout = new FillLayout();
        layout.marginWidth = 4;
        layout.marginHeight = 4;
        toolTipShell.setLayout(layout);

        final Label label = new Label(toolTipShell, SWT.NONE);
        label.setForeground(getDisplay().getSystemColor(SWT.COLOR_INFO_FOREGROUND));
        label.setBackground(getDisplay().getSystemColor(SWT.COLOR_INFO_BACKGROUND));

        if (WindowSystem.isCurrentWindowSystem(WindowSystem.AQUA)) {
            label.setText(Messages.getString("HTMLEditor.HoldDownCommandAndClickLinkToOpen")); //$NON-NLS-1$
        } else {
            label.setText(Messages.getString("HTMLEditor.HoldDownControlAndClickLinkToOpen")); //$NON-NLS-1$
        }

        label.pack();
        toolTipShell.pack();
    }

    private ToolBar createLinkToolBar(final CoolBar parent) {
        final ToolBar toolBar = new ToolBar(parent, toolbarStyle);

        linkButtonItem = new ToolItem(toolBar, SWT.PUSH);
        linkButtonItem.setToolTipText(Messages.getString("HTMLEditor.ConvertToHyperlinkToolTip")); //$NON-NLS-1$
        linkButtonItem.setImage(imageHelper.getImage("/images/htmleditor/link.gif")); //$NON-NLS-1$
        linkButtonItem.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetSelected(final SelectionEvent e) {
                /*
                 * IE and Mozilla do not give us the existing link on the
                 * selecte item (if there is one) through any
                 * queryCommandValue() invocation. To retrieve the current link,
                 * call a special method on the HTMLEditor.
                 */
                final Object existingLinkObject = evaluate("return editor.getLinkUnderSelection() || '';"); //$NON-NLS-1$
                final String initialValue = ((existingLinkObject instanceof String)
                        && existingLinkObject.toString().length() > 0) ? (String) existingLinkObject : "http://"; //$NON-NLS-1$

                final StringInputDialog dialog = new StringInputDialog(getShell(),
                        Messages.getString("HTMLEditor.HyperlinkInputDialogPrompt"), //$NON-NLS-1$
                        initialValue, Messages.getString("HTMLEditor.HyperlinkInputDialogTitle"), //$NON-NLS-1$
                        "HTMLEditor.toolBar.linkButtonItem"); //$NON-NLS-1$

                /*
                 * Allow an empty string, to unset the link.
                 */
                dialog.setRequired(false);

                if (dialog.open() == IDialogConstants.OK_ID) {
                    if (dialog.getInput() != null && dialog.getInput().length() > 0) {
                        /*
                         * Disallow Javascript links. These would be removed
                         * during the round-trip through save anyway (see
                         * HtmlFilter).
                         */

                        if (dialog.getInput().startsWith("javascript:") == false) //$NON-NLS-1$
                        {
                            doEditorCommand("CreateLink", false, dialog.getInput()); //$NON-NLS-1$
                        }
                    } else {
                        doEditorCommand("Unlink", false, null); //$NON-NLS-1$
                    }
                }
            }
        });

        return toolBar;

    }

    private ToolBar createIndentToolBar(final CoolBar parent) {
        final ToolBar toolBar = new ToolBar(parent, toolbarStyle);

        indentButtonItem = new ToolItem(toolBar, SWT.PUSH);
        indentButtonItem.setToolTipText(Messages.getString("HTMLEditor.IncreaseIndentToolTip")); //$NON-NLS-1$
        indentButtonItem.setImage(imageHelper.getImage("/images/htmleditor/indent.gif")); //$NON-NLS-1$
        indentButtonItem.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetSelected(final SelectionEvent e) {
                doEditorCommand("Indent", false, null); //$NON-NLS-1$
            }
        });

        outdentButtonItem = new ToolItem(toolBar, SWT.PUSH);
        outdentButtonItem.setToolTipText(Messages.getString("HTMLEditor.DecreaseIndentToolTip")); //$NON-NLS-1$
        outdentButtonItem.setImage(imageHelper.getImage("/images/htmleditor/outdent.gif")); //$NON-NLS-1$
        outdentButtonItem.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetSelected(final SelectionEvent e) {
                doEditorCommand("Outdent", false, null); //$NON-NLS-1$
            }
        });

        return toolBar;
    }

    private ToolBar createListToolBar(final CoolBar parent) {
        final ToolBar toolBar = new ToolBar(parent, toolbarStyle);

        unorderedListButtonItem = new ToolItem(toolBar, SWT.PUSH);
        unorderedListButtonItem.setToolTipText(Messages.getString("HTMLEditor.BulletsToolTip")); //$NON-NLS-1$
        unorderedListButtonItem.setImage(imageHelper.getImage("/images/htmleditor/unordered_list.gif")); //$NON-NLS-1$
        unorderedListButtonItem.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetSelected(final SelectionEvent e) {
                doEditorCommand("InsertUnorderedList", false, null); //$NON-NLS-1$
            }
        });

        orderedListButtonItem = new ToolItem(toolBar, SWT.PUSH);
        orderedListButtonItem.setToolTipText(Messages.getString("HTMLEditor.NumberingToolTip")); //$NON-NLS-1$
        orderedListButtonItem.setImage(imageHelper.getImage("/images/htmleditor/ordered_list.gif")); //$NON-NLS-1$
        orderedListButtonItem.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetSelected(final SelectionEvent e) {
                doEditorCommand("InsertOrderedList", false, null); //$NON-NLS-1$
            }
        });

        return toolBar;
    }

    private ToolBar createAlignmentToolBar(final CoolBar parent) {
        final ToolBar toolBar = new ToolBar(parent, toolbarStyle);

        /*
         * Alignment buttons go in a drop-down menu.
         */

        /*
         * DropdownToolItemSelectionListener handles much of the drop-down menu
         * work for us. Just add menu items to it.
         */
        final ToolItem alignmentButtonItem = new ToolItem(toolBar, SWT.DROP_DOWN);
        final DropdownToolItemSelectionListener alignmentToolItemSelectionListener = new DropdownToolItemSelectionListener(
                alignmentButtonItem);

        leftMenuItem = alignmentToolItemSelectionListener.addMenuItem(
                Messages.getString("HTMLEditor.AlignLeftToolTip"), //$NON-NLS-1$
                imageHelper.getImage("/images/htmleditor/align_left.gif"), //$NON-NLS-1$
                Messages.getString("HTMLEditor.AlignLeftToolTip"), //$NON-NLS-1$
                SWT.NONE, new MenuItemSelectedHandler() {
                    @Override
                    public void onMenuItemSelected(final MenuItem menuItem) {
                        doEditorCommand("JustifyLeft", false, null); //$NON-NLS-1$
                        updateToolBar();
                    }
                });

        centerMenuItem = alignmentToolItemSelectionListener.addMenuItem(
                Messages.getString("HTMLEditor.CenterToolTip"), //$NON-NLS-1$
                imageHelper.getImage("/images/htmleditor/align_center.gif"), //$NON-NLS-1$
                Messages.getString("HTMLEditor.CenterToolTip"), //$NON-NLS-1$
                SWT.NONE, new MenuItemSelectedHandler() {
                    @Override
                    public void onMenuItemSelected(final MenuItem menuItem) {
                        doEditorCommand("JustifyCenter", false, null); //$NON-NLS-1$
                        updateToolBar();
                    }
                });

        rightMenuItem = alignmentToolItemSelectionListener.addMenuItem(
                Messages.getString("HTMLEditor.AlignRightToolTip"), //$NON-NLS-1$
                imageHelper.getImage("/images/htmleditor/align_right.gif"), //$NON-NLS-1$
                Messages.getString("HTMLEditor.AlignRightToolTip"), //$NON-NLS-1$
                SWT.NONE, new MenuItemSelectedHandler() {
                    @Override
                    public void onMenuItemSelected(final MenuItem menuItem) {
                        doEditorCommand("JustifyRight", false, null); //$NON-NLS-1$
                        updateToolBar();
                    }
                });

        justifyMenuItem = alignmentToolItemSelectionListener.addMenuItem(
                Messages.getString("HTMLEditor.JustifyToolTip"), //$NON-NLS-1$
                imageHelper.getImage("/images/htmleditor/align_full.gif"), //$NON-NLS-1$
                Messages.getString("HTMLEditor.JustifyToolTip"), //$NON-NLS-1$
                SWT.NONE, new MenuItemSelectedHandler() {
                    @Override
                    public void onMenuItemSelected(final MenuItem menuItem) {
                        doEditorCommand("JustifyFull", false, null); //$NON-NLS-1$
                        updateToolBar();
                    }
                });

        alignmentToolItemSelectionListener.setDefaultToolItem(leftMenuItem);
        alignmentButtonItem.addSelectionListener(alignmentToolItemSelectionListener);

        return toolBar;
    }

    private ToolBar createFontToolBar(final CoolBar parent) {
        final ToolBar toolBar = new ToolBar(parent, toolbarStyle);

        fontNameCombo = new Combo(toolBar, SWT.NONE);
        fontNameCombo.setItems(getSystemFontNames(getShell()));
        fontNameCombo.setToolTipText(Messages.getString("HTMLEditor.FontNameToolTip")); //$NON-NLS-1$
        fontNameCombo.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetDefaultSelected(final SelectionEvent e) {
                widgetSelected(e);
            }

            @Override
            public void widgetSelected(final SelectionEvent e) {
                applyFontName(fontNameCombo.getText());

                /*
                 * Throw the focus back into the browser so the user can type
                 * with their newly selected font.
                 */
                browser.setFocus();
            }
        });

        final ToolItem fontNameComboItem = new ToolItem(toolBar, SWT.SEPARATOR);
        fontNameComboItem.setWidth(140);
        fontNameComboItem.setControl(fontNameCombo);

        fontSizeCombo = new Combo(toolBar, SWT.NONE);
        fontSizeCombo.setToolTipText(Messages.getString("HTMLEditor.FontSizeToolTip")); //$NON-NLS-1$
        fontSizeCombo.setItems(new String[] { "1", //$NON-NLS-1$
                "2", //$NON-NLS-1$
                "3", //$NON-NLS-1$
                "4", //$NON-NLS-1$
                "5", //$NON-NLS-1$
                "6", //$NON-NLS-1$
                "7" //$NON-NLS-1$
        });
        fontSizeCombo.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetDefaultSelected(final SelectionEvent e) {
                widgetSelected(e);
            }

            @Override
            public void widgetSelected(final SelectionEvent e) {
                applyFontSize(fontSizeCombo.getText());

                /*
                 * Throw the focus back into the browser so the user can type
                 * with their newly selected size.
                 */
                browser.setFocus();
            }
        });

        final ToolItem fontSizeComboItem = new ToolItem(toolBar, SWT.SEPARATOR);
        fontSizeComboItem.setWidth(80);
        fontSizeComboItem.setControl(fontSizeCombo);

        return toolBar;
    }

    private ToolBar createFormatToolBar(final CoolBar parent) {
        final ToolBar toolBar = new ToolBar(parent, toolbarStyle);

        boldButtonItem = new ToolItem(toolBar, SWT.CHECK);
        boldButtonItem.setToolTipText(Messages.getString("HTMLEditor.BoldToolTip")); //$NON-NLS-1$
        boldButtonItem.setImage(imageHelper.getImage("/images/htmleditor/bold.gif")); //$NON-NLS-1$
        boldButtonItem.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetSelected(final SelectionEvent e) {
                doEditorCommand("Bold", false, null); //$NON-NLS-1$
            }
        });

        italicButtonItem = new ToolItem(toolBar, SWT.CHECK);
        italicButtonItem.setToolTipText(Messages.getString("HTMLEditor.ItalicToolTip")); //$NON-NLS-1$
        italicButtonItem.setImage(imageHelper.getImage("/images/htmleditor/italic.gif")); //$NON-NLS-1$
        italicButtonItem.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetSelected(final SelectionEvent e) {
                doEditorCommand("Italic", false, null); //$NON-NLS-1$
            }
        });

        underlineButtonItem = new ToolItem(toolBar, SWT.CHECK);
        underlineButtonItem.setToolTipText(Messages.getString("HTMLEditor.UnderlineToolTip")); //$NON-NLS-1$
        underlineButtonItem.setImage(imageHelper.getImage("/images/htmleditor/underline.gif")); //$NON-NLS-1$
        underlineButtonItem.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetSelected(final SelectionEvent e) {
                doEditorCommand("Underline", false, null); //$NON-NLS-1$
            }
        });

        return toolBar;
    }

    private ToolBar createColorToolBar(final CoolBar parent) {
        final ToolBar toolBar = new ToolBar(parent, toolbarStyle);

        final ToolItem foregroundColorButtonItem = new ToolItem(toolBar, SWT.PUSH);
        foregroundColorButtonItem.setToolTipText(Messages.getString("HTMLEditor.ForegroundColorToolTip")); //$NON-NLS-1$
        foregroundColorButtonItem.setImage(imageHelper.getImage("/images/htmleditor/foreground_color.gif")); //$NON-NLS-1$
        foregroundColorButtonItem.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetSelected(final SelectionEvent e) {
                final String newColor = pickColor(browser.getShell(),
                        convertHTMLColorObject(queryCommandValue("ForeColor"))); //$NON-NLS-1$

                if (newColor != null) {
                    doEditorCommand("ForeColor", false, newColor); //$NON-NLS-1$
                }
            }
        });

        final ToolItem backgroundColorButtonItem = new ToolItem(toolBar, SWT.PUSH);
        backgroundColorButtonItem.setToolTipText(Messages.getString("HTMLEditor.BackgroundColorToolTip")); //$NON-NLS-1$
        backgroundColorButtonItem.setImage(imageHelper.getImage("/images/htmleditor/background_color.gif")); //$NON-NLS-1$
        backgroundColorButtonItem.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetSelected(final SelectionEvent e) {
                final String newColor = pickColor(browser.getShell(),
                        convertHTMLColorObject(queryCommandValue("BackColor"))); //$NON-NLS-1$

                if (newColor != null) {
                    doEditorCommand("BackColor", false, newColor); //$NON-NLS-1$
                }
            }
        });

        return toolBar;
    }

    private void createCoolItem(final CoolBar coolBar, final ToolBar toolBar) {
        Check.notNull(coolBar, "coolBar"); //$NON-NLS-1$
        Check.notNull(toolBar, "toolBar"); //$NON-NLS-1$

        // Compute the size of the toolbar
        toolBar.pack();
        final Point toolBarSize = toolBar.getSize();

        // Create a CoolItem to hold the toolbar
        final CoolItem coolItem = new CoolItem(coolBar, SWT.NONE);
        coolItem.setControl(toolBar);

        // Set the preferred size to what was computed from the toolbar
        final Point coolItemSize = coolItem.computeSize(toolBarSize.x, toolBarSize.y);

        /*
         * SWT Quirk (Bug?)
         *
         * The cool item should have its PREFERRED size set to the result of its
         * OWN computeSize() calculation, but its MINIMUM size should be set to
         * its "child" TOOL BAR's computed size. I think it should rightly use
         * the same size (its OWN computed size) for minimum size, but this
         * leaves way too much empty space in the right side of the toolbar.
         */
        coolItem.setPreferredSize(coolItemSize);
        coolItem.setMinimumSize(toolBarSize);
    }

    /**
     * <p>
     * Initializes the Javascript editor program in the {@link Browser}. Call
     * this method just once during construction.
     * </p>
     * <p>
     * Main initialization steps:
     * <ol>
     * <li>Loading the HTML editor document resource (this is the Javascript
     * editor program)</li>
     * <li>Setting the text in the browser control (the HTML editor document
     * just loaded)</li>
     * <li>Attaching a {@link LocationListener} to reject navigation to other
     * URLs</li>
     * <li>Attaching browser function callbacks so Javascript can call back into
     * Java</li>
     * </ol>
     * </p>
     */
    protected void initializeBrowser() {
        final InputStream stream = this.getClass().getResourceAsStream(HTMLEditor.HTML_EDITOR_RESOURCE);

        if (stream == null) {
            throw new MissingResourceException(
                    MessageFormat.format("Could not load HTML editor resource {0}", //$NON-NLS-1$
                            HTMLEditor.HTML_EDITOR_RESOURCE), HTMLEditor.HTML_EDITOR_RESOURCE, ""); //$NON-NLS-1$
        }

        try {
            try {
                final Reader reader = new InputStreamReader(stream, HTMLEditor.HTML_EDITOR_RESOURCE_ENCODING);
                try {
                    final StringBuilder text = new StringBuilder();

                    final char[] buffer = new char[1024];
                    int read;
                    while ((read = reader.read(buffer, 0, buffer.length)) != -1) {
                        text.append(buffer, 0, read);
                    }

                    browser.setText(text.toString());

                    /*
                     * Attach Javascript functions. These classes will only load
                     * with Eclipse/SWT 3.5 or later, so don't store field
                     * references to them or insert "import" statements for them
                     * to ensure the HTMLEditor class can be loaded with older
                     * Eclipse/SWT versions.
                     */
                    new com.microsoft.tfs.client.common.ui.controls.generic.html.EditorReadyFunction(
                            browser.getBrowser(), "HTMLEditorLoadComplete", //$NON-NLS-1$
                            this);

                    new com.microsoft.tfs.client.common.ui.controls.generic.html.ModifiedFunction(
                            browser.getBrowser(), "HTMLEditorDocumentBodyInnerHTMLModified", //$NON-NLS-1$
                            this);

                    new com.microsoft.tfs.client.common.ui.controls.generic.html.SelectionChangedFunction(
                            browser.getBrowser(), "HTMLEditorSelectionChanged", //$NON-NLS-1$
                            this);

                    new com.microsoft.tfs.client.common.ui.controls.generic.html.MouseLinkEnterFunction(
                            browser.getBrowser(), "HTMLEditorMouseLinkEnter", //$NON-NLS-1$
                            this);

                    new com.microsoft.tfs.client.common.ui.controls.generic.html.MouseLinkExitFunction(
                            browser.getBrowser(), "HTMLEditorMouseLinkExit", //$NON-NLS-1$
                            this);
                } catch (final IOException e) {
                    HTMLEditor.log.error("Error reading from stream", e); //$NON-NLS-1$
                    browser.setText(MessageFormat.format("<html><body>Internal error: {0}</body></html>", //$NON-NLS-1$
                            e.getLocalizedMessage()));
                } finally {
                    try {
                        reader.close();
                    } catch (final IOException e) {
                        HTMLEditor.log.error("Error closing reader", e); //$NON-NLS-1$
                    }
                }
            } catch (final UnsupportedEncodingException e) {
                HTMLEditor.log.error(MessageFormat.format("Couldn''t create InputStreamReader with encoding {0}", //$NON-NLS-1$
                        HTMLEditor.HTML_EDITOR_RESOURCE_ENCODING), e);
                browser.setText(MessageFormat.format("<html><body>Internal error: {0}</body></html>", //$NON-NLS-1$
                        e.getLocalizedMessage()));
            }
        } finally {
            try {
                stream.close();
            } catch (final IOException e) {
                HTMLEditor.log.error("Error closing resource stream", e); //$NON-NLS-1$
            }
        }
    }

    /**
     * Throws {@link IllegalStateException} if the editor is not ready yet (has
     * not completed Javascript initialization).
     */
    private void ensureEditorLoadCompleted() {
        if (editorReady == false) {
            throw new IllegalStateException(MessageFormat.format("{0} not ready", HTMLEditor.class.getName())); //$NON-NLS-1$
        }
    }

    /**
     * Executes the given script in {@link #browser}, logging if the execution
     * failed.
     *
     * @param script
     *        the script to execute (must not be <code>null</code>)
     * @return <code>true</code> if the script succeeded, <code>false</code> if
     *         it failed
     * @see Browser#execute(String)
     */
    private boolean execute(final String script) {
        Check.notNull(script, "script"); //$NON-NLS-1$

        log.debug(script);
        if (browser.execute(script) == false) {
            HTMLEditor.log.error(MessageFormat.format("Error executing script ''{0}''", script)); //$NON-NLS-1$
            return false;
        }

        return true;
    }

    /**
     * Evaluates the given script in {@link #browser}, logging and rethrowing
     * any errors that occur.
     *
     * @param script
     *        the script to evaluate (must not be <code>null</code>)
     * @return the script's return value
     * @see Browser#evaluate(String)
     */
    private Object evaluate(final String script) {
        Check.notNull(script, "script"); //$NON-NLS-1$

        try {
            log.debug(script);
            return browser.evaluate(script);
        } catch (final SWTException e) {
            HTMLEditor.log
                    .error(MessageFormat.format("Error evaluating script ''{0}'': {1}", script, e.getMessage())); //$NON-NLS-1$
            throw e;
        }
    }

    /**
     * Invoked by the Javascript editor when it has completed its load.
     */
    public void onEditorReady() {
        log.debug("onEditorReady"); //$NON-NLS-1$

        editorReady = true;

        applyFontSize(DEFAULT_FONT_SIZE);

        /*
         * Call once because we don't detect cursor movement on first click in
         * Mozilla.
         */
        updateToolBar();

        /*
         * Prevent the browser from opening its own links. Safari/WebKit wants
         * to do this aggressively when the user clicks on links, and doesn't
         * give us the chance to obey the user's browser preferences (internal
         * vs. external app).
         */
        browser.getBrowser().addLocationListener(new LocationAdapter() {
            @Override
            public void changing(final LocationEvent event) {
                log.debug("Blocking location change event"); //$NON-NLS-1$

                event.doit = false;
            }
        });

        /*
         * Prevent new browser windows from opening. WindowEvent's design is
         * very limiting. In order to handle the event one must give it a
         * Browser to open in, which doesn't work with our BrowserFacade class.
         * We can cancel the event by not assigning anything to the browser
         * field and setting required to true (see WidowEvent).
         */
        browser.getBrowser().addOpenWindowListener(new OpenWindowListener() {
            @Override
            public void open(final WindowEvent event) {
                log.debug("Blocking open window event"); //$NON-NLS-1$

                event.required = true;
                event.browser = null;
            }
        });

        editorReadyListener.editorReady();
    }

    /**
     * Invoked by the Javascript editor when the editable document content
     * changes.
     */
    protected void onModified() {
        log.debug("onModified"); //$NON-NLS-1$

        final Event e = new Event();
        e.widget = this;

        final ModifyEvent me = new ModifyEvent(e);

        ((ModifyListener) modifyListeners.getListener()).modifyText(me);
    }

    /**
     * Invoked by the Javascript editor when the selection changes (including
     * cursor position)
     */
    public void onSelectionChanged() {
        log.debug("onSelectionChanged"); //$NON-NLS-1$

        updateToolBar();
    }

    /**
     * Invoked by the Javascript editor when the mouse enters the area (hovers)
     * over a link.
     */
    public void onMouseLinkEnter() {
        log.debug("onMouseLinkEnter"); //$NON-NLS-1$

        scheduleToolTip();
    }

    /**
     * Invoked by the Javascript editor when the mouse exits the area over a
     * link.
     */
    public void onMouseLinkExit() {
        log.debug("onMouseLinkExit"); //$NON-NLS-1$

        hideToolTip();
    }

    private void scheduleToolTip() {
        // Hide any existing tool tip and cancel its timer
        hideToolTip();

        log.trace(MessageFormat.format("Scheduling tool tip to show in {0} ms", TOOL_TIP_SHOW_DELAY_MILLISECONDS)); //$NON-NLS-1$

        toolTipShellTimer = new Timer();
        toolTipShellTimer.schedule(new TimerTask() {
            @Override
            public void run() {
                getDisplay().asyncExec(new Runnable() {
                    @Override
                    public void run() {
                        showToolTip();
                    }
                });
            }
        }, TOOL_TIP_SHOW_DELAY_MILLISECONDS);

    }

    private void showToolTip() {
        // Calculate size and position
        final Point size = toolTipShell.computeSize(SWT.DEFAULT, SWT.DEFAULT);
        final Point position = new Point(getDisplay().getCursorLocation().x,
                getDisplay().getCursorLocation().y + 20);

        // Ensure it will be on the screen
        if (position.x + size.x > getDisplay().getBounds().width) {
            position.x = getDisplay().getBounds().width - size.x;
        }
        if (position.y + size.y > getDisplay().getBounds().height) {
            position.y = getDisplay().getBounds().height - size.y;
        }

        toolTipShell.setBounds(position.x, position.y, size.x, size.y);

        log.trace("Showing tool tip"); //$NON-NLS-1$
        toolTipShell.setVisible(true);

        // Schedule a timer to hide the tooltip
        toolTipShellTimer = new Timer();
        toolTipShellTimer.schedule(new TimerTask() {
            @Override
            public void run() {
                getDisplay().asyncExec(new Runnable() {
                    @Override
                    public void run() {
                        log.trace("Tool tip timer fired"); //$NON-NLS-1$
                        hideToolTip();
                    }
                });
            }
        }, TableTooltipLabelManager.getPlatformDefaultTooltipTimeout());

    }

    private void hideToolTip() {
        if (toolTipShellTimer != null) {
            log.trace("Clearing tool tip timer"); //$NON-NLS-1$
            toolTipShellTimer.cancel();
            toolTipShellTimer = null;
        }

        if (toolTipShell.isDisposed() == false) {
            log.trace("Hiding tool tip window"); //$NON-NLS-1$
            toolTipShell.setVisible(false);
        }
    }

    /**
     * Updates the tool bar enablement and push states to match the current
     * state of the editor (cursor position in text, etc.).
     */
    private void updateToolBar() {
        final String currentFontName = getCurrentFontName();
        if (currentFontName != null) {
            fontNameCombo.setText(currentFontName);
        }
        fontNameCombo.setEnabled(queryCommandEnabled("FontName")); //$NON-NLS-1$

        final String currentFontSize = getCurrentFontSize();
        if (currentFontSize != null) {
            fontSizeCombo.setText(currentFontSize);
        }
        fontSizeCombo.setEnabled(queryCommandEnabled("FontSize")); //$NON-NLS-1$

        boldButtonItem.setEnabled(queryCommandEnabled("Bold")); //$NON-NLS-1$
        boldButtonItem.setSelection(queryCommandState("Bold")); //$NON-NLS-1$

        italicButtonItem.setEnabled(queryCommandEnabled("Italic")); //$NON-NLS-1$
        italicButtonItem.setSelection(queryCommandState("Italic")); //$NON-NLS-1$

        underlineButtonItem.setEnabled(queryCommandEnabled("Underline")); //$NON-NLS-1$
        underlineButtonItem.setSelection(queryCommandState("Underline")); //$NON-NLS-1$

        leftMenuItem.setEnabled(queryCommandEnabled("JustifyLeft")); //$NON-NLS-1$
        leftMenuItem.setSelection(queryCommandState("JustifyLeft")); //$NON-NLS-1$

        centerMenuItem.setEnabled(queryCommandEnabled("JustifyCenter")); //$NON-NLS-1$
        centerMenuItem.setSelection(queryCommandState("JustifyCenter")); //$NON-NLS-1$

        rightMenuItem.setEnabled(queryCommandEnabled("JustifyRight")); //$NON-NLS-1$
        rightMenuItem.setSelection(queryCommandState("JustifyRight")); //$NON-NLS-1$

        justifyMenuItem.setEnabled(queryCommandEnabled("JustifyFull")); //$NON-NLS-1$
        justifyMenuItem.setSelection(queryCommandState("JustifyFull")); //$NON-NLS-1$

        /*
         * Do not update the alignment menu with the current selection; the menu
         * should keep the user's previously selected item available for format
         * application.
         */

        unorderedListButtonItem.setEnabled(queryCommandEnabled("InsertUnorderedList")); //$NON-NLS-1$

        orderedListButtonItem.setEnabled(queryCommandEnabled("InsertOrderedList")); //$NON-NLS-1$

        linkButtonItem.setEnabled(queryCommandEnabled("CreateLink")); //$NON-NLS-1$
    }

    private String getCurrentFontName() {
        final Object fontName = queryCommandValue("FontName"); //$NON-NLS-1$

        if (fontName instanceof String) {
            final String fontNameString = ((String) fontName);

            /*
             * Mozilla gives us an empty string when it's the default font name
             * for the document. Use the Mozilla-specific getComputedStyle()
             * method instead.
             *
             * IE gives us an empty string when it's the default font, but it
             * doesn't support getComputedStyle(), so skip it.
             */
            if (fontNameString.length() == 0) {
                if (browser.getBrowserType().equals("ie")) //$NON-NLS-1$
                {
                    return null;
                }

                final Object computedPropertyFontFamily = evaluate(
                        "return editor.richEditor.getComputedStyle(editor.richEditor.document.body, null).getPropertyValue('font-family');"); //$NON-NLS-1$
                if (computedPropertyFontFamily instanceof String) {
                    return (String) computedPropertyFontFamily;
                }
            }

            return fontNameString;
        }

        return null;
    }

    private String getCurrentFontSize() {
        final Object fontSize = queryCommandValue("FontSize"); //$NON-NLS-1$
        if (fontSize != null) {
            String fontSizeString = ""; //$NON-NLS-1$

            if (fontSize instanceof Double) {
                // IE gives us a double, but integers look nicer to the user.
                fontSizeString = Long.toString(Math.round((Double) fontSize));
            } else if (fontSize instanceof String) {
                fontSizeString = ((String) fontSize);

                /*
                 * Mozilla gives us an empty string when it's the default size
                 * for the document. Use the Mozilla-specific getComputedStyle()
                 * method instead.
                 *
                 * IE gives us an empty string when it's the default size, but
                 * it doesn't support getComputedStyle(), so skip it.
                 */
                if (fontSizeString.length() == 0) {
                    if (browser.getBrowserType().equals("ie") == false) //$NON-NLS-1$
                    {
                        final Object computedPropertyFontSize = evaluate(
                                "return editor.richEditor.getComputedStyle(editor.richEditor.document.body, null).getPropertyValue('font-size');"); //$NON-NLS-1$
                        if (computedPropertyFontSize instanceof String) {
                            fontSizeString = (String) computedPropertyFontSize;
                        }
                    }
                }
            }

            return fontSizeString;
        }

        return null;
    }

    /**
     * Gets a list of available system fonts.
     *
     * @param shell
     *        the shell from which to list fonts (must not be <code>null</code>)
     * @return the list of font names (never <code>null</code>)
     */
    public String[] getSystemFontNames(final Shell shell) {
        Check.notNull(shell, "shell"); //$NON-NLS-1$

        final Set<String> names = new HashSet<String>();

        /*
         * Ignore font names which start with an @ sign. These show up on
         * Windows and I don't know what the symbol means, but they're all
         * duplicates (?) of other fonts so it seems safe to ignore them.
         */

        // Once for all the scalable fonts
        FontData[] fontDataArray = shell.getDisplay().getFontList(null, true);
        for (int i = 0; i < fontDataArray.length; i++) {
            if (fontDataArray[i].getName().startsWith("@") == false) //$NON-NLS-1$
            {
                names.add(fontDataArray[i].getName());
            }
        }

        // Twice for the non-scalable fonts
        fontDataArray = shell.getDisplay().getFontList(null, false);
        for (int i = 0; i < fontDataArray.length; i++) {
            if (fontDataArray[i].getName().startsWith("@") == false) //$NON-NLS-1$
            {
                names.add(fontDataArray[i].getName());
            }
        }

        final String[] namesArray = names.toArray(new String[names.size()]);
        Arrays.sort(namesArray);

        return namesArray;
    }

    /**
     * Opens a dialog to let the user pick a color.
     *
     * @param shell
     *        the shell on which to open the dialog (must not be
     *        <code>null</code>)
     * @param initialColor
     *        the initial color to set in the dialog, or <code>null</code> to
     *        use the dialog default
     * @return the color the user picked, or <code>null</code> if the dialog was
     *         cancelled
     */
    public String pickColor(final Shell shell, final RGB initialColor) {
        Check.notNull(shell, "shell"); //$NON-NLS-1$

        /*
         * Mac OS X is Extremely Weird Here!
         *
         * On Mac OS X, we open the ColorDialog and it's automatically hooked
         * into the selection in Safari! Changing colors in the dialog while
         * it's open immediately changes the color in the document, before the
         * dialog even closes (we're blocking in this method). We always get
         * null back from dialog.open() when the dialog is closed, so we don't
         * set anything in the document, but that's OK because Safari is
         * magically updating its document for us. Very weird!
         */

        final ColorDialog dialog = new ColorDialog(shell);

        if (initialColor != null) {
            dialog.setRGB(initialColor);
        }

        final RGB rgb = dialog.open();
        if (rgb != null) {
            return String.format("#%1$02x%2$02x%3$02x", rgb.red, rgb.green, rgb.blue); //$NON-NLS-1$
        }
        return null;
    }

    /**
     * Converts the {@link Object} we get back from the browser when we want the
     * foreground or background color into an {@link RGB}. IE gives us a
     * {@link Double} object, Firefox gives us a {@link String} in the format
     * "#000000" or "rgb(0,0,0)".
     *
     * @param o
     *        the object to convert (may be <code>null</code>
     * @return the converted {@link RGB} or <code>null</code> if the object
     *         could not be converted
     */
    private RGB convertHTMLColorObject(final Object o) {
        if (o == null) {
            return null;
        }

        if (o instanceof String) {
            /*
             * Handle both #000000 and rgb(0,0,0) styles. Mozilla alternates
             * between them.
             */
            final String s = (String) o;
            log.debug("convertHTMLColorObject called with string " + s); //$NON-NLS-1$

            if (s.startsWith("#")) //$NON-NLS-1$
            {
                /*
                 * This implementation assumes 24 bits of color info. Will there
                 * ever be more in this string?
                 */
                final int value = Integer.parseInt(s.substring(1), 16);
                return new RGB((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF);
            } else if (s.toLowerCase(Locale.ENGLISH).startsWith("rgb")) //$NON-NLS-1$
            {
                final Matcher matcher = RGB_CSS_COLOR_PATTERN.matcher(s);

                if (matcher.find() && matcher.groupCount() == 3) {
                    return new RGB(Integer.parseInt(matcher.group(1).trim()),
                            Integer.parseInt(matcher.group(2).trim()), Integer.parseInt(matcher.group(3).trim()));
                } else {
                    log.warn(MessageFormat.format("Couldn''t parse color object from string ''{0}''", s)); //$NON-NLS-1$
                    return null;
                }
            }
        } else if (o instanceof Double) {
            final long value = Math.round((Double) o);
            log.debug(MessageFormat.format("convertHTMLColorObject called with Double {0} (rounded to long {1})", //$NON-NLS-1$
                    Double.toString(((Double) o)), Long.toString(value)));

            /*
             * Always seems to be little-endian (on little-endian Windows
             * platforms). Not sure of a reliable way to detect the format IE
             * will supply us, so just use little endian for now.
             */

            return new RGB((int) value & 0xFF, (int) (value >> 8) & 0xFF, (int) (value >> 16) & 0xFF);
        }

        return null;
    }

    private void applyFontName(final String fontName) {
        doEditorCommand("FontName", false, fontName); //$NON-NLS-1$
    }

    private void applyFontSize(final String fontSize) {
        doEditorCommand("FontSize", false, fontSize); //$NON-NLS-1$
    }

    /**
     * Tests whether the specified editor command can be successfully executed
     * using execCommand given the current state of the document.
     */
    private boolean queryCommandEnabled(final String commandIdentifier) {
        Check.notNull(commandIdentifier, "command"); //$NON-NLS-1$

        final StringBuffer cmd = new StringBuffer();
        cmd.append("return editor.queryCommandEnabled(\""); //$NON-NLS-1$
        cmd.append(commandIdentifier);
        cmd.append("\") || ''"); //$NON-NLS-1$

        final Object ret = browser.evaluate(cmd.toString());

        if (ret instanceof Boolean == false) {
            return false;
        }

        return ((Boolean) ret).booleanValue();
    }

    /**
     * Gets the current state of the specified command.
     */
    private boolean queryCommandState(final String commandIdentifier) {
        Check.notNull(commandIdentifier, "command"); //$NON-NLS-1$

        final StringBuffer cmd = new StringBuffer();
        cmd.append("return editor.queryCommandState(\""); //$NON-NLS-1$
        cmd.append(commandIdentifier);
        cmd.append("\") || ''"); //$NON-NLS-1$

        final Object ret = browser.evaluate(cmd.toString());

        if (ret instanceof Boolean == false) {
            return false;
        }

        return ((Boolean) ret).booleanValue();
    }

    /**
     * Gets the value of the document, range, or selection for the given
     * command.
     */
    private Object queryCommandValue(final String commandIdentifier) {
        Check.notNull(commandIdentifier, "command"); //$NON-NLS-1$

        final StringBuffer cmd = new StringBuffer();
        cmd.append("return editor.queryCommandValue(\""); //$NON-NLS-1$
        cmd.append(commandIdentifier);
        cmd.append("\") || ''"); //$NON-NLS-1$

        return browser.evaluate(cmd.toString());
    }

    private void doEditorCommand(final String command, final boolean showDefaultUserInterface, final String value) {
        Check.notNull(command, "command"); //$NON-NLS-1$

        String commandString = ""; //$NON-NLS-1$
        if (value == null) {
            commandString = MessageFormat.format("editor.execCommand(\"{0}\", {1}, null)", //$NON-NLS-1$
                    command, Boolean.toString(showDefaultUserInterface));
        } else {
            commandString = MessageFormat.format("editor.execCommand(\"{0}\", {1}, \"{2}\")", //$NON-NLS-1$
                    command, Boolean.toString(showDefaultUserInterface),
                    escapeStringForJavascriptExpression(value));
        }

        // Execute does logging.
        execute(commandString);
    }

    /**
     * Adds the given listener to the list of listeners which will be notified
     * when the editor content changes.
     *
     * @param listener
     *        the listener to add (must not be <code>null</code>)
     */
    public void addModifyListener(final ModifyListener listener) {
        Check.notNull(listener, "listener"); //$NON-NLS-1$

        modifyListeners.addListener(listener);
    }

    /**
     * Removes the given listener from the list of listeners who will be
     * notified when the editor content changes.
     *
     * @param listener
     *        the listener to remove (must not be <code>null</code>)
     */
    public void removeModifyListener(final ModifyListener listener) {
        Check.notNull(listener, "listener"); //$NON-NLS-1$

        modifyListeners.removeListener(listener);
    }

    /**
     * Adds a location changed listener to the supporting {@link Browser}.
     *
     * @param listener
     *        the location listener to add (must not be <code>null</code>)
     * @see Browser#addLocationListener(LocationListener)
     */
    public void addLocationListener(final LocationListener listener) {
        browser.addLocationListener(listener);
    }

    /**
     * Removes a location changed listener from the supporting {@link Browser}.
     *
     * @param listener
     *        the location listener to remove (must not be <code>null</code>)
     * @see Browser#removeLocationListener(LocationListener)
     */
    public void removeLocationListener(final LocationListener listener) {
        browser.removeLocationListener(listener);
    }

    /**
     * <p>
     * Gets the innerHTML content from the body element of the HTML editor's
     * inner iframe. Because this content comes from inside a body element, it
     * will not contain &lt;html&gt;, &lt;head&gt;, or &lt;body&gt; tags.
     * </p>
     * <p>
     * <h3>Warning</h3>
     * <p>
     * Only call this method after the editor has completed loading (see
     * {@link HTMLEditor})!
     * </p>
     *
     * @return the HTML content in the body element
     */
    public String getHTML() {
        ensureEditorLoadCompleted();

        return (String) evaluate("return editor.getDocumentBodyInnerHTML() || '';"); //$NON-NLS-1$
    }

    /**
     * <p>
     * Sets the innerHTML content inside the body element of the HTML editor
     * inner iframe. Because this content goes inside a body element, it should
     * not contain &lt;html&gt;, &lt;head&gt;, or &lt;body&gt; tags.
     * </p>
     * <p>
     * <h3>Warning</h3>
     * <p>
     * Only call this method after the editor has completed loading (see
     * {@link HTMLEditor})!
     * </p>
     *
     * @param html
     *        the HTML to set in the body element (must not be <code>null</code>
     *        )
     */
    public void setHTML(final String html) {
        Check.notNull(html, "htmlContent"); //$NON-NLS-1$
        ensureEditorLoadCompleted();

        /*
         * Skip updating the contents if the new contents are the same, because
         * setting the contents on some Browser implementations (Firefox on
         * Linux) resets the caret position to 0. This is a simple work-around
         * and saves update work at the expense of one extra read. Other
         * solutions may involve saving/restoring the caret.
         */
        if (html.equals(getHTML())) {
            return;
        }

        execute(MessageFormat.format("editor.setDocumentBodyInnerHTML(\"{0}\");", //$NON-NLS-1$
                escapeStringForJavascriptExpression(html)));
    }

    /**
     * Escapes the given string so it can be used as a string literal (use
     * double quotes to surround) in a Javascript expression passed to
     * {@link #evaluate(String)}.
     *
     * @param string
     *        the string to escape (must not be <code>null</code>)
     * @return the escaped string (never <code>null</code>)
     */
    private String escapeStringForJavascriptExpression(String string) {
        if (string.length() == 0) {
            return string;
        }

        /*
         * Escape 1 backslash with 2 backslashes (wow, that's a lot of chars to
         * do this: double them once for the Java compiler, double them again to
         * make it through the regular expression engine). This must be done
         * before the newline replacement, so we don't double up the (required
         * single) backslashes for the newline sequences.
         */
        string = string.replaceAll("\\\\", "\\\\\\\\"); //$NON-NLS-1$//$NON-NLS-2$

        /*
         * Escape newlines.
         */
        string = string.replaceAll("\\r\\n", "\\\\r\\\\n"); //$NON-NLS-1$ //$NON-NLS-2$
        string = string.replaceAll("\\n", "\\\\n"); //$NON-NLS-1$ //$NON-NLS-2$
        string = string.replaceAll("\\r", "\\\\r"); //$NON-NLS-1$ //$NON-NLS-2$

        /*
         * Escape all double quotes with backslash and double-quote, so the
         * outer double-quoting of the contents in an expression aren't
         * interrupted.
         */
        string = string.replaceAll("\"", "\\\\\""); //$NON-NLS-1$ //$NON-NLS-2$

        return string;
    }

    /**
     * <p>
     * Configures the editor to allow or prevent changes to the document.
     * </p>
     * <p>
     * <h3>Warning</h3>
     * <p>
     * Only call this method after the editor has completed loading (see
     * {@link HTMLEditor})!
     * </p>
     *
     * @param value
     *        if <code>true</code> the document is editable by the user, if
     *        <code>false</code> the document is not editable
     */
    public void setReadOnly(final boolean value) {
        ensureEditorLoadCompleted();

        if (browser.execute(MessageFormat.format("editor.setReadOnly({0});", Boolean.toString(value))) == false) //$NON-NLS-1$
        {
            HTMLEditor.log.warn(MessageFormat.format("Error setting editor to read-only = {0}", value)); //$NON-NLS-1$
        }
    }

    /**
     * <p>
     * Gets whether the control is read-only.
     * </p>
     * <p>
     * <h3>Warning</h3>
     * <p>
     * Only call this method after the editor has completed loading (see
     * {@link HTMLEditor})!
     * </p>
     *
     * @return <code>true</code> if the editor is read-only, <code>false</code>
     *         if the editor content may be edited
     */
    public boolean getReadOnly() {
        ensureEditorLoadCompleted();

        return ((Boolean) evaluate("return editor.getReadOnly();")).booleanValue(); //$NON-NLS-1$
    }

    /**
     * Handles the user typing control-enter on an existing link in the editor.
     */
    private void openLinkUnderSelection() {
        log.debug("opening link under selection"); //$NON-NLS-1$

        BusyIndicator.showWhile(getDisplay(), new Runnable() {
            @Override
            public void run() {
                // More immediate feedback.
                hideToolTip();

                final Object linkObject = evaluate("return editor.getLinkUnderSelection() || '';"); //$NON-NLS-1$
                if ((linkObject instanceof String) && linkObject.toString().length() > 0) {
                    openLink((String) linkObject);
                }
            }
        });
    }

    /**
     * Handles the user clicking (or typing control-enter on) an existing link
     * in the editor.
     */
    private void openLinkUnderMouseCursor() {
        log.debug("opening link under mouse cursor"); //$NON-NLS-1$

        BusyIndicator.showWhile(getDisplay(), new Runnable() {
            @Override
            public void run() {
                // More immediate feedback.
                hideToolTip();

                final Object linkObject = evaluate("return editor.getLinkUnderMouseCursor() || '';"); //$NON-NLS-1$
                if ((linkObject instanceof String) && linkObject.toString().length() > 0) {
                    openLink((String) linkObject);
                }
            }
        });
    }

    private void openLink(final String urlString) {
        Check.notNull(urlString, "urlString"); //$NON-NLS-1$

        // TODO handle proprietary TFS link types here

        try {
            final URI uri = URIUtils.newURI(urlString);
            BrowserFacade.launchURL(uri, null);
        } catch (final IllegalArgumentException e) {
            log.error("Could not parse " + urlString, e); //$NON-NLS-1$

            MessageBoxHelpers.errorMessageBox(getShell(), Messages.getString("HTMLEditor.ErrorOpeningLink"), //$NON-NLS-1$
                    MessageFormat.format(Messages.getString("HTMLEditor.LinkCouldNotBeParsedAsURLFormat"), //$NON-NLS-1$
                            urlString, e.getLocalizedMessage()));
            return;
        }
    }
}