Font family selector

22nd January 2018

A little while ago intellective.co commissioned a couple of additional tools for ContentTools for a project they were working on. One of the tools they requested was a font family selector and since someone else recently asked me for the same tool I thought I'd share the code.

Very quickly - A big thanks to Ritesh Dalal at intellective.co for allowing me to share the code in this entry.

The code

As a number of tools were developed for the project the code for the font family selector involves several classes, on the plus side these additional classes are a good basis for building other tools that use a similar UX (for example we used the same approach to build a block type selector that included H1-H6).

The popup menu

The following code defines a set of menu class that support a popup menu UI component required for the font family selector:

class MenuItemUI extends ContentTools.ComponentUI

    # An item within a menu

    constructor: (label='', labelSafe=False) ->
        super()

        # The label to display for the menu
        @_label = label

        # Flag indicating if the label for the menu item is HTML safe
        @_labelSafe = labelSafe

        # Flag indicating if the menu item has focused
        @_focus = false

    blur: () ->
        # Remove focus from the item
        if @dispatchEvent(@createEvent('blur', {menuItem: this}))
            @_focus = false
            if @isMounted()
                @removeCSSClass('int-menu-item--focused')

    focus: () ->
        # Give the item focus
        if @dispatchEvent(@createEvent('focus', {menuItem: this}))
            @_focus = true
            if @isMounted()
                @addCSSClass('int-menu-item--focused')

    focused: () ->
        # Return true if the menu item currently has focus
        return @_focus

    mount: () ->
        # Mount the menu item
        unless @parent().isMounted()
            return

        # Create the menu item
        @_domElement = @constructor.createDiv(['int-menu-item'])
        if @_focus
            @_domElement.classList.add('int-menu-item--focused')

        @_domLabel = @constructor.createDiv(['int-menu-item__label'])

        if @_labelSafe
            @_domLabel.innerHTML = @_label
        else
            @_domLabel.textContent = @_label

        @_domElement.appendChild(@_domLabel)
        @parent().domElement().appendChild(@_domElement)

        # Add events
        @_addDOMEventListeners()

    select: () =>
        # Select the menu item (simply triggers an event that can be 
        # listened for).
        @dispatchEvent(@createEvent('select', {menuItem: this}))

    # Private methods

    _addDOMEventListeners: () ->

        # Focus events
        @_domElement.addEventListener 'mouseover', () =>
            @focus()

        # Select event
        @_domElement.addEventListener 'click', () =>
            @select()


class MenuUI extends ContentTools.ComponentUI

    # A menu

    constructor: () ->
        super()

        # The menu item which currently has focus
        @_focused = null

    attach: (component, index) ->
        # Add a menu item to the menu
        super(component, index)

        component.addEventListener 'focus', (ev) =>
            # Blur the currently focused menu item
            if @_focused
                @_focused.blur()

            # Remember which menu item currently has focus
            @_focused = ev.detail().menuItem

    detatch: (component) ->
        # Remove a menu item from menu
        super(component, index)

        component.removeEventListener 'focus'

    mount: () ->
        # Mount the menu
        unless @parent().isMounted()
            return

        # Create the menu
        @_domElement = @constructor.createDiv(['int-menu'])
        @parent().domElement().appendChild(@_domElement)

        # Mount menu items
        for child in @children()
            child.mount()

        # Add events
        @_addDOMEventListeners()

    unmount: () ->
        # Unmount the menu

        # Unmount menu items
        for child in @children()
            child.unmount()

        super()

        @_removeEventListener()

    # Menu navigation methods

    focused: () ->
        # Return the menu item that currently has focus
        return @_focused

    next: () ->
        # Focus on the next menu item
        children = @children()

        if @_focused
            # Check if this is the last item and if so cycle round
            if @_focused == children[children.length - 1]
                # Cycle round
                children[0].focus()
            else
                # Focus on the next item
                children[children.indexOf(@_focused) + 1].focus()

        else if children.length > 0
            # If no menu item currently has focus then focus on the first
            # item.
            children[0].focus()

    previous: () ->
        # Focus on the previous menu item
        children = @children()

        if @_focused
            # Check if this is the first item and if so cycle round
            if @_focused == children[0]
                # Cycle round
                children[children.length - 1].focus()
            else
                # Focus on the next item
                children[children.indexOf(@_focused) - 1].focus()


        else if @children().length > 0
            # If no menu item currently has focus then focus on the 
            # first item.
            children[children.length - 1].focus()

    select: () ->
        # Select the currently focused menu item
        if @_focused
            @_focused.select()

    # Private methods

    _addDOMEventListeners: () ->
        # Add keyboard support for navigating the menu

        @_keyboardNav = (ev) =>
            ev.preventDefault()
            ev.stopPropagation()

            switch ev.keyCode
                when 13 # Return
                    @select()

                when 38 # Up arrow
                    @previous()

                when 40 # Down arrow
                    @next()

        document.addEventListener 'keydown', @_keyboardNav

    _removeEventListener: () ->
        # Remove keyboard events
        document.removeEventListener 'keydown', @_keyboardNav


class PopUpMenuUI extends ContentTools.AnchoredDialogUI

    # A pop-up menu

    constructor: (menu) ->
        super()

        # Attach the menu we'll display in the pop up
        @_menu = menu
        @attach(menu)

    menu: () ->
        # Return the menu associated with the pop-up menu
        return @_menu

    mount: () ->
        # Mount the pop-up menu to the DOM

        # Create the menu
        @_domElement = @constructor.createDiv([
            'ct-widget',
            'int-pop-up-menu'
            ])
        @parent().domElement().appendChild(@_domElement)

        # Set the position of the pop-up menu
        @_contain()
        @_domElement.style.top = "#{ @_position[1] }px"
        @_domElement.style.left = "#{ @_position[0] }px"

        # Mount menu
        @_menu.mount()

    unmount: () ->
        # Unmount the pop-up menu
        @_menu.unmount()
        super()

Font family tool

The next section of code relates to the font family selector tool itself:

class FontFamily extends Tool

    ContentTools.ToolShelf.stow(this, 'font-family')

    @label = 'Font family'
    @icon = 'font-family'

    @fontFamilies = [
        {
            name: 'Arial',
            cssClass: 'font-arial'
        }, {
            name: 'Courier',
            cssClass: 'font-courier'
        }, {
            name: 'Helvetica',
            cssClass: 'font-helvetica'
        }, {
            name: 'Times',
            cssClass: 'font-times'
        }
    ]

    @canApply: (element, selection) ->
        # Return true if the tool can be applied to the current
        # element/selection.

        if element.isFixed()
            return false

        return element.content != undefined and
                ['Text'].indexOf(element.type()) != -1

    @isApplied: (element, selection) ->
        # Return true if one of the font families the tool is configured 
        # to apply is currently applied to the selected element.

        if element.isFixed()
            return false

        if ['Text'].indexOf(element.type()) == -1
            return false

        for family in @fontFamilies
            if element.hasCSSClass(family.cssClass)
                return true

        return false

    @apply: (element, selection, callback) ->
        # Present the user with a pop-up menu of possible font-families 
        # to select from and apply the changes if/when a heading tag is 
        # selected.

        # Dispatch `apply` event
        toolDetail = {
            'tool': this,
            'element': element,
            'selection': selection
            }

        if not @dispatchEditorEvent('tool-apply', toolDetail)
            return

        # Blur the element so that it's not affect whilst the user 
        # selects a heading tag (we apply the `ce-element--focused` to 
        # the element after we blur it so that it still appears to be 
        # focused).
        element.storeState()
        element.blur()
        element.domElement().classList.add('ce-element--focused')

        # Build the menu
        menu = new popupmenu.MenuUI()

        for family in @fontFamilies
            menuItem = new popupmenu.MenuItemUI(
                """
                <div class='int-font-family ]  [ #{family.cssClass} ]'>
                    #{ family.name }
                </div>
                """,
                true
                )

            # Store the tag name associated with the menu item
            menuItem.__family = family
            menuItem.__families = @fontFamilies

            # If the menu item represents the current tag name for the 
            # element then give it focus initially.
            if element.hasCSSClass(family.cssClass)
                menuItem.focus()

            # Capture when an item is selected so we can apply the 
            # associated heading tag.
            menuItem.addEventListener 'select', (ev) ->
                family = ev.detail().menuItem.__family
                families = ev.detail().menuItem.__families

                # Remove any existing font family classes
                for otherFamily in families
                    element.removeCSSClass(otherFamily.cssClass)

                # Apply the new font family class
                element.addCSSClass(family.cssClass)

                # Close the modal and with it the popup
                modal.dispatchEvent(
                    modal.createEvent('click', {applied: true})
                )

            menu.attach(menuItem)

        # Display the pop-up menu
        app = ContentTools.EditorApp.get()
        modal = new ContentTools.ModalUI(
            transparent=true, 
            allowScrolling=true
        )
        popupMenu = new popupmenu.PopUpMenuUI(menu)

        # When the modal is clicked on the pop-up menu should close
        modal.addEventListener 'click', (ev) ->
            # Unmount the modal and popup menu
            @unmount()
            popupMenu.hide()

            # Restore the selection
            element.focus()
            element.restoreState()

            # Call any supplied callback
            callback(ev.detail() and ev.detail().applied)

            # Dispatch `applied` event
            if ev.detail() and ev.detail().applied
                Heading.dispatchEditorEvent('tool-applied', toolDetail)

        # Set the position to display the pop-up menu at
        popupMenu.position(@getToolPosition())

        app.attach(modal)
        app.attach(popupMenu)
        modal.show()
        popupMenu.show()

SASS

The following are styles for the popup menu and an example of how to set up the styles required for the different font families within your site.

// Popup menu
.ct-widget {
    &.int-pop-up-menu {
        background: #fff;
        border-radius: 4px;
        box-shadow: 0 2px 1px rgba(0, 0, 0, 0.2);
        margin-left: -4px;
        margin-top: 4px;
        padding: 12px 0;
        position: fixed;
        z-index: 10010;
    }

    .int-menu-item {
        color: #4e4e4e;
        cursor: pointer;
        min-width: 160px;
        padding: 0 12px;

        &--focused {
            background: #dfdfdf;
        }

        &__label {
            height: 48px;
            line-height: 48px;
        }
    }
}

// Font tool
.font {

    // HACK: I've set the `font-family` property here using the 
    // !important modifier which is probably not best approach typically 
    // however for the value to apply with the CT UI you will need to 
    // either apply !important or declate a separate version of the font 
    // modifiers within the scope `.ct-widget` (giving the definition 
    // enough precedence to override the CT reset rule.
    //
    // Anthony Blackshaw, <ant@getme.co.uk> 12 May 2017

    &-arial {
        font-family: arial !important;
    }

    &-courier {
        font-family: courier !important;
    }

    &-helvetica {
        font-family: helvetica !important;
    }

    &-times {
        font-family: times !important;
    }
}

Set up

To use the tool you'll need to do the following:

# 1. Configure the font families for the tool, e.g:

FontFamily.fontFamilies = [
        {
            name: 'Arial',
            cssClass: 'font-arial'
        }, {
            name: 'Courier',
            cssClass: 'font-courier'
        }, {
            name: 'Helvetica',
            cssClass: 'font-helvetica'
        }, {
            name: 'Times',
            cssClass: 'font-times'
        }
]

# 2. Add the tool into your toolbox, e.g:

ContentTools.DEFAULT_TOOLS[0].push('font-family')

That's all folks!