Tools, the toolbox and the tool shelf

We can all agree I went too far with the metaphors (so let's not dwell) - basically a tool is a class that provides some editing function (for example making the currently-selected text bold). The toolbox is a draggable box that contains a set of icons, each representing a tool, and the tool shelf is a registry of tools that allows a tool to be referenced and retrieved using its name.

The code examples in this tutorial use CoffeeScript as this is the recommended language to use to extend the editor, but it is possible to use JavaScript if you prefer. I'd also recommend you familiarize yourself with the project's build process now if you're not already familiar (see the repo).

Adding a <time> tool

For the purpose of the tutorial we're going to create a tool that wraps selected content using the HTML5 <time> tag. When a user chooses our time tool from the toolbox they'll be asked to enter a datetime value in a floating dialog (in the same way the link tool asks the user for a URL).

Creating the Time tool class

All tools are static classes that inherit from the ContentTools.Tool class. Those included in the ContentTools library are namespaced under ContentTools.Tools (this isn't necessary for tools you create unless they become part of the library).

Create a file named time-tool.coffee and add the following outline code for our TimeTool class:

class TimeTool extends ContentTools.Tools.Bold

    # Insert/Remove a <time> tag.

    # Register the tool with the toolshelf
    ContentTools.ToolShelf.stow(@, 'time')

    # The tooltip and icon modifier CSS class for the tool
    @label = 'Time'
    @icon = 'time'

    # The Bold provides a tagName attribute we can override to make inheriting
    # from the class cleaner.
    @tagName = 'time'

    @apply: (element, selection, callback) ->
        # Apply a <time> element to the specified element and selection

    @getDatetime: (element, selection) ->
        # Return any existing `datetime` attribute for the element and selection

Notice that we're inheriting from the Bold tool here, this is a useful starting point as the canApply and isApplied class methods are already defined for us. canApply will return true if there is text selected, isApplied will return true if a <time> tag is already wrapped around all or some of the selected text. If canApply returns false then it holds that isApplied must also return false.

I recommend reviewing the code for these class methods against the Bold tool class to familiarize yourself with their workings.

Asking the time

To be able to add a <time> tag we'll need to ask the user for a datetime value and for this we'll use an anchored dialog similar to the one used by the link tool. Anchored dialogs are positioned /anchored above  the content they are related to.

Add the following code under the Time tool class:

class TimeDialog extends ContentTools.LinkDialog

    # An anchored dialog to support inserting/modifying a <time> tag

    mount: () ->
        super()

        # Update the name and placeholder for the input field provided by the
        # link dialog.
        @_domInput.setAttribute('name', 'time')
        @_domInput.setAttribute('placeholder', 'Enter a date/time/duration...')

        # Remove the new window target DOM element
        @_domElement.removeChild(@_domTargetButton);

    save: () ->
        # Save the datetime.
        detail = {
            datetime: @_domInput.value.trim()
        }
        @dispatchEvent(@createEvent('save', detail))

We're inheriting from the LinkDialog class which accepts an initialValue argument that pre-populates its input field. Since our tool also allows users to update the datetime value of an existing <time> element, we need to be able to read the the datetime value from any existing element. Change the getDateTime class method against the TimeTool class to:

@getDatetime(element, selection) ->
    # Return any existing `datetime` attribute for the element and selection

    # Find the first character in the selected text that has a `time` tag and
    # return its `datetime` value.
    [from, to] = selection.get()
    selectedContent = element.content.slice(from, to)
    for c in selectedContent.characters

        # Does this character have a time tag applied?
        if not c.hasTags('time')
            continue

        # Find the time tag and return the datetime attribute value
        for tag in c.tags()
            if tag.name() == 'a'
                return tag.attr('href')

        return ''

Applying <time>

Back to our TimeTool class now and the apply class method.

@apply: (element, selection, callback) ->
    # Apply the tool to the specified element and selection

    # Store the selection state of the element so we can restore it once done
    element.storeState()

    # Add a fake selection wrapper to the selected text so that it appears to be
    # selected when the focus is lost by the element.
    selectTag = new HTMLString.Tag('span', {'class': 'ct--puesdo-select'})
    [from, to] = selection.get()
    element.content = element.content.format(from, to, selectTag)
    element.updateInnerHTML()

    # Set-up the dialog
    app = ContentTools.EditorApp.get()

    # Add an invisible modal that we'll use to detect if the user clicks away
    # from the dialog to close it.
    modal = new ContentTools.ModalUI(transparent=true, allowScrolling=true)

    modal.addEventListener 'click', () ->
        # Close the dialog
        @unmount()
        dialog.hide()

        # Remove the fake selection from the element
        element.content = element.content.unformat(from, to, selectTag)
        element.updateInnerHTML()

        # Restore the real selection
        element.restoreState()

        # Trigger the callback
        callback(false)

    # Measure a rectangle of the content selected so we can position the
    # dialog centrally to it.
    domElement = element.domElement()
    measureSpan = domElement.getElementsByClassName('ct--puesdo-select')
    rect = measureSpan[0].getBoundingClientRect()

    # Create the dialog
    dialog = new TimeDialog(@getDatetime(element, selection))
    dialog.position([
        rect.left + (rect.width / 2) + window.scrollX,
        rect.top + (rect.height / 2) + window.scrollY
        ])

    # Listen for save events against the dialog
    dialog.addEventListener 'save', (ev) ->
        # Add/Update/Remove the <time>
        datetime = ev.detail().datetime

        # Clear any existing link
        element.content = element.content.unformat(from, to, 'time')

        # If specified add the new time
        if datetime
            time = new HTMLString.Tag('time', {datetime: datetime})
            element.content = element.content.format(from, to, time)

        element.updateInnerHTML()
        element.taint()

        # Close the modal and dialog
        modal.unmount()
        dialog.hide()

        # Remove the fake selection from the element
        element.content = element.content.unformat(from, to, selectTag)
        element.updateInnerHTML()

        # Restore the real selection
        element.restoreState()

        # Trigger the callback
        callback(true)

    app.attach(modal)
    app.attach(dialog)
    modal.show()
    dialog.show()


That's quite a bit of code to take in, let's pull out the interesting parts and look at them in a bit more detail.

When the user selects the time tool we want them to be able to continue to see the selection of text they are applying the tool to. However when the user selects the dialog's datetime input field the existing selection will be lost because the focus will transfer to the input. To solve this we wrap the selected text in a <span> tag with the CSS modifier class ct--puesdo-select to ensure it remains highlighted.

    ...

    # Add a fake selection wrapper to the selected text so that it appears to be
    # selected when the focus is lost by the element.
    selectTag = new HTMLString.Tag('span', {'class': 'ct--puesdo-select'})
    [from, to] = selection.get()
    element.content = element.content.format(from, to, selectTag)
    element.updateInnerHTML()

    ...

We also want our TimeDialog to appear above our selected content, to achieve this we query the bounding rectangle for the selected text and take the center as the position to anchor our dialog.

    ...

    # Measure a rectangle of the content selected so we can position the
    # dialog centrally to it.
    domElement = element.domElement()
    measureSpan = domElement.getElementsByClassName('ct--puesdo-select')
    rect = measureSpan[0].getBoundingClientRect()

    # Create the dialog
    dialog = new TimeDialog(@getDatetime(element, selection))
    dialog.position([
        rect.left + (rect.width / 2) + window.scrollX,
        rect.top + (rect.height / 2) + window.scrollY
        ])

    ...

Finally we need to wrap our text in a new <time> tag - or update/remove an existing <time> tag. We start by removing any existing <time> tag from the selection, then providing a datetime value has been set we add a new <time> tag. Using this approach, if no datetime value is set, the <time> tag is removed.

    ...

    # Listen for save events against the dialog
    dialog.addEventListner 'save', (ev) ->
        # Add/Update/Remove the <time>
        datetime = ev.detail().datetime

        # Clear any existing link
        element.content = element.content.unformat(from, to, 'time')

        # If specified add the new time
        if datetime
            time = new HTMLString.Tag('time', {datetime: datetime})
            element.content = element.content.format(from, to, time)

        element.updateInnerHTML()
        element.taint()

    ...

Adding a tool to the toolbox

Right now, users can't access our brand spanking new time tool because it isn't in their toolbox. This is both a very simple task and a slightly fiddly one.

Let's start with the easy bit. The simplest way to add a new tool to the toolbox is to update the DEFAULT_TOOLS settings. For the moment we'll add our time tool to the end of the first group of tools. Add the following code as the last line in the time-tool.coffee:

ContentTools.DEFAULT_TOOLS[0].push('time')

This adds the tool to the toolbox but visually we won't be able to see much because we haven't added a CSS class modifier for the tool. The tool's @icon attribute is used to build a modifier CSS class name for setting the tools icon. The modifier class in this case will be ce-tool--time. To define a style for the icon we need to update the src/styles/ui/_toolbox.scss file (or if you really wanted to you could create a new file, e.g _custom-toolbox.scss). In the _toolbox.scss file find the ce-tools section (it's at the end of the file) and add a definition for time:

.ct-tool {

    ...

    &--time:before { content: "\e94e"; }
}

The content attribute determines the character that will be displayed by the tool which maps to an icon in our icons.woff font file. You'll have to take my word for it that \e94e is the correct one.

Now the fiddly bit

The editor uses a font that has icons for characters - the font was created using the icomoon font app.

Before you read on! The repo now includes a JSON config file that you can import into icomoon to avoid manually setting up the icons as suggested below. I highly recommend you use it.

To recreate the font and add the additional time icon you'll need to open the icomoon app then select the following icons (the order is not important):

  • pencil
  • droplet
  • image
  • clock
  • undo
  • redo
  • cog
  • bin
  • list-numbered
  • list
  • list2
  • link
  • cross
  • checkmark
  • crop
  • bold
  • italic
  • pagebreak
  • table2
  • paragraph-left
  • paragraph-center
  • paragraph-right
  • indent-increase
  • indent-decrease
  • embed2
  • youtube2
  • clock

Once you've select these icons you then need to choose the Generate font button followed by the Download button. Extract the zip file you downloaded and in the /fonts folder there will be a file named icomoon.woff. Change the name of the file to icon.woff and replace the existing icon.woff in your project.

Putting it all together

If you've been following along at home then it's time to build the new improved version of the editor with our super-duper time tool. Open the Gruntfile.coffee file in the project root and add the following line to the coffee > build task's files option 'path/to/file/time-tools.coffee' as below:

...

coffee:
    options:
        join: true

    build:
        files:
            'src/tmp/content-tools.js': [
                'src/scripts/namespace.coffee'

                ...

                # Custom tools
                'path/to/file/time-tools.coffee'

...

Save your changes, and then at the command prompt enter: grunt build. All being well, Grunt will compile our CoffeeScript and SASS changes.

With your new icons.woff font, content-tools.min.js and and content-tools.min.css in place (either uploaded to your site or just overwritten locally), open an editable page in a new browser window, select some text and click the new time tool (which should appears as a clock face).

By default the <time> element will probably not be styled, so if you want it to be visibly different from the surrounding content you'll need to add some CSS for that.

Here's one I made .