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!