.container
  .text-editor.demo
    div: h1(align='center') ES6 Text Editor/Context Menu
    div(align='center'): small All of this text is editable
    div: br
    div Right click to access the #[big #[i #[u custom context menu]]]. On mobile, a button appears in the corner instead.
    div: small You can still shift+right click to get the normal context menu.
    div: br
    div Simple keyboard shortcuts are also built in. They can contain up to one modifier and one modifier. Following are the ones currently in use.
    div
      ul
        li #[b Ctrl+B] - Bold
        li #[b Ctrl+I] - Italics
        li #[b Ctrl+U] - Underline
        li #[b Tab] - Indent
        li #[b Shift+Tab] - Outdent
    div By the way, the mobile version is not switched on the fly. It is set when the pen first runs.
    div: br
    div $mm is my tiny DOM manipulation library. The syntax is very similar to jQuery, but the purpose is just to provide shortcuts for the JS DOM API. Both Pens only rely on ES6. To clarify, the basic use is as follows.
    div: br
    div: font(face='Consolas') #[big $mm(selector)] - Finds/Wraps an element in $mm
    div: font(face='Consolas') #[big $mm('#{"<div>"}')] - Creates a div tag
    div: br
    div Try messing with the editor settings at the bottom of the JS.
    div: br
    div(align='right') All of this text was written in the editor.

// Profile link styling for self-shilling
a.shill(href='https://codepen.io/milesmanners' target='_blank' title='Made by Miles Manners')
View Compiled
@import url('https://fonts.googleapis.com/css?family=Open+Sans');

$clr-bg: #1C1F2B;
$clr-bg-lgt: lighten($clr-bg, 10%);
$clr-bg-drk: darken($clr-bg, 5%);
$clr-text: $clr-white;
$clr-text-hint: rgba($clr-text, .6);
$clr-primary: $clr-blue;
$clr-secondary: $clr-green;

$anim-dur: 200ms;

$border-radius: 4px;

*, *::before, *::after {
  box-sizing: border-box;
}

html, body {
  width: 100%;
  height: 100%;
}

body {
  background: $clr-bg;
  color: $clr-text;
  margin: 0px;
  font-family: 'Open Sans', sans-serif;
}

.container {
  min-height: 100%;
  display: grid;
  padding: 20px;
}

.text-editor {
  background-color: $clr-bg-lgt;
  color: $clr-text;
  padding: 8px 12px;
  //border: 1px solid rgba($clr-black, 0.15);
  border-radius: $border-radius;
  font-family: inherit;
  outline: none;
  transition: box-shadow $anim-dur, border-color $anim-dur;

  &:hover {
    border-color: rgba($clr-white, .3);
  }

  &:focus {
    border-color: transparent;
    box-shadow: 0 0 0 3px rgba($clr-primary, .8);
  }

  &::placeholder, &::-moz-placeholder {
    color: $clr-text-hint;
    opacity: 1;
  }
}

.row {
  display: flex;
  padding-bottom: 15px;
  align-items: end;
  opacity: 0;
  transition: opacity 400ms;
}

.show {
  opacity: 1;
}

button {
  background: transparent;
  color: $clr-primary;
  padding: 8px 12px;
  border: 1px solid $clr-primary;
  border-radius: $border-radius;
  cursor: pointer;
  transition: background $anim-dur, color $anim-dur;
  
  &:hover {
    background: $clr-primary;
    color: $clr-white;
  }
  
  &.danger {
    color: $clr-red;
    border-color: $clr-red;
    
    &:hover {
      background: $clr-red;
      color: $clr-white;
    }
  }
}

.menu {
  position: fixed;
  background: rgba($clr-bg, .9);
  padding: 5px 0px;
  //border: 1px solid $clr-text-hint;
  box-shadow: 4px 4px 3px rgba($clr-black, .5);
  cursor: default;
  
  &.mobile, &.mobile .menu {
    box-shadow: none;
    padding: 0;
    
    .menu {
      transform: translate3d(0, 5px, 0);
    }
    
    .icon-bar {
      padding: 0;
      i {
        width: 35px;
        height: 35px;
      }
    }
    
    .menu-item {
      margin: 0;
      padding: 5px 10px;
    }
    
    hr {
      margin: 0;
    }
  }
  
  .icon-bar {
    display: flex;
    justify-content: space-between;
    white-space: nowrap;
    font-size: 22px;
    padding: 0 5px;
    
    i {
      flex: 1 1 0;
      width: 30px;
      height: 30px;
      display: flex;
      align-items: center;
      justify-content: center;

      &:hover {
        background: rgba($clr-primary, .5);
      }
    }
  }
  
  .menu-item {
    display: flex;
    align-items: center;
    margin: 0 5px;
    padding: 0px 5px;
    white-space: nowrap;
    
    &:hover { background: rgba($clr-primary, .5) }
    i {
      width: 14px;
      &:first-child { margin-right: 8px }
      &:last-child {
        justify-self: flex-end;
      }
    }
    
    .label {
      flex-grow: 1;
    }
  }
}

.fa-font-increase {
  &:before {
    content: "\f031";
  }
  
  &:after {
    font-size: 50%;
    content: "\f067";
  }
}

.fa-font-decrease {
  &:before {
    content: "\f031";
  }
  
  &:after {
    font-size: 50%;
    content: "\f068";
  }
}

hr {
  height: 0px;
  margin: 5px 0;
  color: transparent;
  border: none;
  border-bottom: 1px solid rgba($clr-text, .2);
}

.fa-angle-right::before {
  display: block;
  margin-top: 1px;
  margin-left: 8px;
}

.open-menu {
  display: flex;
  align-items: center;
  justify-content: center;
  position: fixed;
  width: 40px;
  height: 40px;
  border-radius: $border-radius;
  background: rgba($clr-bg-drk, .5);
  cursor: pointer;
}

// Profile link styling for self-shilling
.shill {
  position: fixed;
  background-image: url('https://s3-us-west-2.amazonaws.com/s.cdpn.io/726875/profile/profile-512.jpg?100000');
  background-size:     cover;
  width: 40px;
  height: 40px;
  bottom: 30px;
  right: 30px;
  border-radius: 20px;
  opacity: .4;
  filter: blur(.5px);
  transition: opacity $anim-dur, blur $anim-dur;
  
  &:hover {
    opacity: 1;
    filter: none;
  }
}
View Compiled
/* jshint esversion: 6, asi: true, boss: true */

let isMobile = window.matchMedia('only screen and (max-width: 760px)').matches

function buildMenu (groups) {
  // Build the menu container
  let menu = $mm('<div>', { class: 'menu' })

  // Build groups
  for (let g of groups) {
    if (g.type === 'dropdown') {
      // Build the dropdown item
      let dropdown = buildMenuItem(g, g.type)
      menu.append(dropdown)
      menu.append($mm('<hr>'))
      
      // Build the submenu
      let submenu = buildMenu(g.groups)
      // Keep track of the close timer so we can delay hiding the dropdown
      submenu.timeoutId = null
      
      // Show the submenu on hover over the dropdown
      dropdown.on('mouseenter', e => {
        e.preventDefault()

        // Don't close the dropdown
        if (submenu.timeoutId)
          clearTimeout(submenu.timeoutId)

        setTimeout(() => {
          // Close other submenus
          for (let submenu of menu.findAll('.menu'))
            submenu.remove()

          // Position the menu and add it to the DOM
          let rect = dropdown.getBoundingClientRect()
          submenu.style.top = `${rect.top - 5}px`
          submenu.style.right = 'auto'
          submenu.style.bottom = 'auto'
          submenu.style.left = `${rect.right}px`
          dropdown.append(submenu)

          // Try to keep the menu on screen
          if (rect.right + submenu.offsetWidth > window.innerWidth) {
            submenu.style.left = `${window.innerWidth - submenu.offsetWidth}px`
          }

          if (rect.top - 5 + submenu.offsetHeight > window.innerHeight) {
            submenu.style.top = `${rect.bottom + 5 - submenu.offsetHeight}px`
          }
        })
      }, isMobile ? 50 : 0)
      
      // Start the close timer on leaving the dropdown
      dropdown.on('mouseleave', e => {
        submenu.timeoutId = setTimeout(() => submenu.remove(), 500)
      })
    } else {
      let attrs = { class: `group ${g.type === 'icon' ? 'icon-bar' : 'list'}`}

      menu.append(buildMenuGroup(g.items, g.type, attrs))
      menu.append($mm('<hr>'))
    }
  }
  menu.find('hr:last-child').remove()
  
  return menu
}

function buildMenuGroup (items, type, attrs = {}) {
  // Build the list
  let list = $mm('<div>', attrs)
  
  for (let i of items) {
    // Build the list item based on type
    let el = buildMenuItem (i, type)
    
    // Enable the action if there is one
    if ('action' in i) {
      el.on('mousedown', (e, ...args) => {
        e.preventDefault()
        i.action(e, ...args)
      })
    }
    
    list.append(el)
  }
  
  return list
}

function buildMenuItem (item, type) {
  switch (type) {
    case 'icon':
      return $mm('<i>', {
        class: `fa fa-${item.icon}`,
        title: item.label
      })
    case 'dropdown':
      return $mm('<div>', {
        class: 'menu-item dropdown',
        html: `<i class='drop-icon fa fa-${'icon' in item ? item.icon : 'fw'}'></i><div class='label'>${item.label}</div><i class="fa fa-angle-right"></i>`
      })
    default:
      return $mm('<div>', {
        class: 'menu-item',
        html: `<i class='fa fa-${'icon' in item ? item.icon : 'fw'}'></i><div class='label'>${item.label}</div>`
      })
  }
}

function findShortcuts (groups) {
  let shortcuts = []
  
  for (let g of groups) {
    if ('groups' in g) {
      shortcuts = [...shortcuts ,...findShortcuts(g.groups)]
    } else {
      for (let i of g.items)
        if ('shortcut' in i)
          shortcuts.push(i)
    }
  }
  
  return shortcuts
}

function editor (opts) {
    // Make the editor div editable
    this.attr('contenteditable', 'true')
    let textEditor = this
    
    // Add the custom menu and keyboard shortcuts
    if (opts && 'menu' in opts && 'groups' in opts.menu) {
      // Build the menu
      let menu = buildMenu(opts.menu.groups)

      if (isMobile) {
        menu.addClass('mobile')
        let rect = this.getBoundingClientRect()
        let openBtn = $mm('<div>', {
          class: 'open-menu',
          style: {
            top: `${rect.top}px`,
            right: `${rect.left}px`
          }
        })
        let openIcon = $mm('<i>', { class: 'fa fa-bars fa-2x' })
        openBtn.append(openIcon)
        $mm('body').append(openBtn)
        
        let openBtnW = openBtn.offsetWidth,
            openBtnH = openBtn.offsetHeight
        
        openBtn.on('mousedown', e => {
          e.preventDefault()

          setTimeout(() => {
            // Remove any lingering submenus
            for (let submenu of menu.findAll('.menu'))
              submenu.remove()
            
            // Add the menu as invisible
            menu.style.opacity = 0
            menu.style.top = `${rect.top}px`
            menu.style.right = `${rect.left}px`
            menu.style.bottom = 'auto'
            menu.style.left = 'auto'
            $mm('body').append(menu)

            // Hide the icon and resize the button
            openIcon.style.opacity = 0
            openBtn.style.width = `${menu.offsetWidth}px`
            openBtn.style.height = `${menu.offsetHeight}px`

            // Show the menu
            menu.style.opacity = 1

            // Remove the menu if the user clicks somewhere else or the editor loses focus
            function removeMenu (e) {
              e.preventDefault()

              let target = $mm(e.target)
              if (!target.hasClass('menu') && !target.hasClass('dropdown') && !target.hasClass('group') && !target.parent.hasClass('dropdown')) {
                document.removeEventListener('mousedown', removeMenu)

                menu.remove()
                openBtn.style.width = `${openBtnW}px`
                openBtn.style.height = `${openBtnH}px`
                openIcon.style.opacity = 1
              }
            }

            $mm(document).on('mousedown', removeMenu)
          }, 50)
        })
      } else {
        // Open the custom menu on right click
        this.on('contextmenu', e => {
          e.preventDefault()

          // Remove any lingering submenus
          for (let submenu of menu.findAll('.menu'))
            submenu.remove()
          
          // Position the menu and add it to the DOM
          menu.style.top = `${e.clientY}px`
          menu.style.right = 'auto'
          menu.style.bottom = 'auto'
          menu.style.left = `${e.clientX}px`
          $mm('body').append(menu)

          // Try to keep the menu on screen
          if (e.clientX + menu.offsetWidth > window.innerWidth) {
            menu.style.left = `${window.innerWidth - menu.offsetWidth}px`
          }

          if (e.clientY + menu.offsetHeight > window.innerHeight) {
            menu.style.top = 'auto'
            menu.style.bottom = `${window.innerHeight - e.clientY}px`
          }

          // Remove the menu if the user clicks somewhere else or the editor loses focus
          function removeMenu (e) {
            if (e.type === 'focusout' ? e.target !== textEditor : true) {
              let target = $mm(e.target)
              if (menu && !target.hasClass('menu') && !target.hasClass('dropdown') && !target.hasClass('group') && !target.parent.hasClass('dropdown')) {
                $mm(document).removeEventListener('mousedown', removeMenu)
                $mm(document).removeEventListener('focusout', removeMenu)

                for (let submenu of menu.findAll('.menu'))
                  submenu.remove()
                menu.remove()
              }
            }
          }

          $mm(document).on('mousedown focusout', removeMenu)
        })
      }
      
      // Find all of the shortcuts
      let shortcuts = findShortcuts(opts.menu.groups)
      
      // Enable shortcuts
      this.on('keydown', (e, ...args) => {
        let modifiers = {
          Ctrl: e.ctrlKey,
          Alt: e.altKey,
          Shift: e.shiftKey,
          Meta: e.metaKey
        }
        let modifier = modifiers.Ctrl || modifiers.Alt || modifiers.Shift || modifiers.Meta
        
        for (let s of shortcuts) {
          if (e.key === s.shortcut.key && ('modifier' in s.shortcut ? modifiers[s.shortcut.modifier] : !modifier)) {
            e.preventDefault()
            s.action(e, ...args)
          }
        }
      })
    }
    
    return this
  }

// Some example groups
let ex = {
  fontSize: [
    {
      icon: 'font-increase',
      label: 'Increase Font',
      action: () => document.execCommand('increaseFontSize')
    }, {
      icon: 'font-decrease',
      label: 'Decrease Font',
      action: () => document.execCommand('decreaseFontSize')
    }
  ],
  font: [
    {
      icon: 'font',
      label: 'Arial',
      action: () => document.execCommand('fontName', false, 'Arial')
    }, {
      icon: 'font',
      label: 'Consolas',
      action: () => document.execCommand('fontName', false, 'Consolas')
    }, {
      icon: 'font',
      label: 'Open Sans',
      action: () => document.execCommand('fontName', false, 'Open Sans')
    }, {
      icon: 'font',
      label: 'Verdana',
      action: () => document.execCommand('fontName', false, 'Verdana')
    }
  ],
  alignment: [
    {
      icon: 'align-left',
      label: 'Left',
      action: () => document.execCommand('justifyLeft')
    }, {
      icon: 'align-center',
      label: 'Center',
      action: () => document.execCommand('justifyCenter')
    }, {
      icon: 'align-right',
      label: 'Right',
      action: () => document.execCommand('justifyRight')
    }, {
      icon: 'align-justify',
      label: 'Justify',
      action: () => document.execCommand('justifyFull')
    }
  ],
  textLook: [
    {
      icon: 'bold',
      label: 'Bold',
      shortcut: { modifier: 'Ctrl', key: 'b' },
      action: () => document.execCommand('bold')
    }, {
      icon: 'italic',
      label: 'Italic',
      shortcut: { modifier: 'Ctrl', key: 'i' },
      action: () =>  document.execCommand('italic')
    }, {
      icon: 'underline',
      label: 'Underline',
      shortcut: { modifier: 'Ctrl', key: 'u' },
      action: () => document.execCommand('underline')
    }
  ],
  console: [
    {
      icon: 'arrow-right',
      label: 'Log',
      action: () => console.log($mm('.text-editor').innerHTML)
    }
  ],
  headers: [
    {
      icon: 'header',
      label: 'Header 1',
      action: () => document.execCommand('heading', false, 'H1')
    }, {
      icon: 'header',
      label: 'Header 2',
      action: () => document.execCommand('heading', false, 'H2')
    }, {
      icon: 'header',
      label: 'Header 3',
      action: () => document.execCommand('heading', false, 'H3')
    }, {
      icon: 'header',
      label: 'Header 4',
      action: () => document.execCommand('heading', false, 'H4')
    }, {
      icon: 'header',
      label: 'Header 5',
      action: () => document.execCommand('heading', false, 'H5')
    }, {
      icon: 'header',
      label: 'Header 6',
      action: () => document.execCommand('heading', false, 'H6')
    }
  ],
  indention: [
    {
      icon: 'indent',
      label: 'Indent',
      shortcut: {key: 'Tab'},
      action: () => document.execCommand('indent')
    }, {
      icon: 'outdent',
      label: 'Outdent',
      shortcut: {modifier: 'Shift', key: 'Tab'},
      action: () => document.execCommand('outdent')
    }
  ],
  list: [
    {
      icon: 'list-ol',
      label: 'Ordered List',
      action: () => document.execCommand('insertOrderedList')
    }, {
      icon: 'list-ul',
      label: 'Unordered List',
      action: () => document.execCommand('insertUnorderedList')
    }
  ]
}

ex.fontDropdown = {
  type: 'dropdown',
  icon: 'font',
  label: 'Font',
  groups: [
    { type: 'icon', items: ex.fontSize },
    { type: 'text', items: ex.font }
  ]
}

ex.headerDropdown = {
  type: 'dropdown',
  icon: 'header',
  label: 'Header',
  groups: [
    { type: 'text', items: ex.headers }
  ]
}

ex.alignmentDropdown = {
  type: 'dropdown',
  icon: 'align-left',
  label: 'Alignment',
  groups: [
    { type: 'icon', items: ex.indention },
    { type: 'text', items: ex.alignment }
  ]
}

ex.indentionDropdown = {
  type: 'dropdown',
  icon: 'indent',
  label: 'Indention',
  groups: [
    { type: 'text', items: ex.indention }
  ]
}

ex.consoleDropdown = {
  type: 'dropdown',
  icon: 'arrow-right',
  label: 'Console',
  groups: [
    { type: 'text', items: ex.console }
  ]
}

ex.moreDropdown = {
  type: 'dropdown',
  icon: 'ellipsis-h',
  label: 'More',
  groups: [
    ex.indentionDropdown,
    ex.consoleDropdown
  ]
}

// Provide a way to access the prebuilt examples later
$mm.merge(editor, { prebuilt: ex })

// Adds text editor functionality
$mm.fn.extend({
  editor: editor
})

// Example Use
if ($mm('.text-editor.demo')) {
  $mm('.text-editor.demo').editor({
    menu: {
      groups: [
        {type: 'icon', items: ex.textLook},
        ex.fontDropdown,
        ex.headerDropdown,
        ex.alignmentDropdown,
        {type: 'icon', items: ex.list}
      ]
    }
  })
}

External CSS

  1. https://raw.githubusercontent.com/mrmlnc/material-color/master/material-color.scss
  2. https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css

External JavaScript

  1. https://codepen.io/milesmanners/pen/WOeOrV.js