.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, &::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}
]
}
})
}