Home

Awesome

Mobiledoc Kit CI Build Status

<img width="300" src="https://bustle.github.io/mobiledoc-kit/demo/mobiledoc-logo-color-small.png" alt="Mobiledoc Logo"/>

Mobiledoc Kit is a framework-agnostic library for building WYSIWYG editors supporting rich content via cards.

Libraries

This repository hosts the core Mobiledoc Kit library. If you want to use Mobiledoc Kit to create a WYSIWYG editor you have the following options:

EnvironmentLibrary
Plain JavaScriptmobiledoc-kit (this repo)
Emberember-mobiledoc-editor
Reactreact-mobiledoc-editor

If you only want to use the Mobiledoc-Kit runtime, for rendering mobiledoc posts only (not editing or creating them), you can use:

Output Format/EnvironmentLibrary
Plain JavaScript In-Browser (DOM)mobiledoc-dom-renderer
Server-Side Rendering (HTML)see mobiledoc-dom-renderer's Rendering HTML section
Server-Side Rendering (Text-only, e.g. SEO)mobiledoc-text-renderer
In-Browser (DOM) Rendering, with Emberember-mobiledoc-dom-renderer
React Server and Browser Renderermobiledoc-react-renderer
🔮 Render Mobiledoc as VDOM by passing React or React-like createElement functionmobiledoc-vdom-renderer

Mobiledoc is a deliberately simple and terse format, and you are encouraged to write your own renderer if you have other target output formats (e.g., a PDF renderer, an iOS Native Views Renderer, etc.).

Other 3rd Party Libraries:

EnvironmentLibrary
Pythonmobiledoc-py

Demo

Try a demo at bustle.github.io/mobiledoc-kit/demo.

API Documentation

API Documentation is available online.

Intro to Mobiledoc Kit

To learn more about the ideas behind Mobiledoc and the editor (note that the editor used to be named Content-Kit), see these blog posts:

The Mobiledoc kit saves posts in Mobiledoc format.

Usage

The Mobiledoc.Editor class is invoked with an element to render into and optionally a Mobiledoc to load. For example:

const simpleMobiledoc = {
  version: '0.3.2',
  markups: [],
  atoms: [],
  cards: [],
  sections: [[1, 'p', [[0, [], 0, 'Welcome to Mobiledoc']]]],
}
const element = document.querySelector('#editor')
const options = { mobiledoc: simpleMobiledoc }
const editor = new Mobiledoc.Editor(options)
editor.render(element)

options is an object which may include the following properties:

The editor leverages unicode characters, so HTML documents must opt in to UTF8. For example this can be done by adding the following to an HTML document's <head>:

<meta charset="utf-8" />

Editor API

Position API

A Position object represents a location in a document. For example your cursor may be at a position, text may be inserted at a position, and a range has a starting position and an ending position.

Position objects are returned by several APIs, for example deleteRange returns a position. Some methods, like splitSection accept a position as an argument.

A position can be created for any point in a document with section#toPosition(offset).

Position API includes:

Range API

Range represents a range of a document. A range has a starting position (head), ending position (tail), and a direction (for example highlighting text left-to-right is a forward direction, highlighting right-to-left is a backward direction).

Ranges are returned by several APIs, but most often you will be interested in the current range selected by the user (be it their cursor or an actual selection). This can be accessed at editor#range. Several post editor APIs expect a range as an argument, for example setRange or deleteRange.

Ranges sport several public APIs for manipulation, each of which returns a new, unique range instance:

Editor Lifecycle Hooks

API consumers may want to react to given interaction by the user (or by a programmatic edit of the post). Lifecycle hooks provide notification of change and opportunity to edit the post where appropriate.

Register a lifecycle hook by calling the hook name on the editor with a callback function. For example:

editor.didUpdatePost(postEditor => {
  let { range } = editor
  let cursorSection = range.head.section

  if (cursorSection.text === 'add-section-when-i-type-this') {
    let section = editor.builder.createMarkupSection('p')
    postEditor.insertSectionBefore(section, cursorSection.next)
    postEditor.setRange(new Mobiledoc.Range(section.headPosition))
  }
})

The available lifecycle hooks are:

For more details on the lifecycle hooks, see the Editor documentation.

Programmatic Post Editing

A major goal of the Mobiledoc kit is to allow complete customization of user interfaces using the editing surface. The programmatic editing API allows the creation of completely custom interfaces for buttons, hot-keys, and other interactions.

To change the post in code, use the editor.run API. For example, the following usage would mark currently selected text as "strong":

editor.run(postEditor => {
  postEditor.toggleMarkup('strong')
})

It is important that you make changes to posts, sections, and markers through the run and postEditor API. This API allows the Mobiledoc editor to conserve and better understand changes being made to the post.

editor.run(postEditor => {
  const mention = postEditor.builder.createAtom('mention', 'Jane Doe', { id: 42 })
  // insert at current cursor position:
  // or should the user have to grab the current position from the editor first?
  postEditor.insertMarkers(editor.range.head, [mention])
})

For more details on the API of postEditor, see the API documentation.

For more details on the API for the builder, required to create new sections atoms, and markers, see the builder API.

Configuring hot keys

The Mobiledoc editor allows the configuration of hot keys and text expansions. For instance, the hot-key command-B to make selected text bold, is registered internally as:

const boldKeyCommand = {
  str: 'META+B',
  run(editor) {
    editor.run(postEditor => postEditor.toggleMarkup('strong'))
  },
}
editor.registerKeyCommand(boldKeyCommand)

All key commands must have str and run properties as shown above.

str describes the key combination to use and may be a single key, or modifier(s) and a key separated by +, e.g.: META+K (cmd-K), META+SHIFT+K (cmd-shift-K)

Modifiers can be any of CTRL, META, SHIFT, or ALT.

The key can be any of the alphanumeric characters on the keyboard, or one of the following special keys:

BACKSPACE, TAB, ENTER, ESC, SPACE, PAGEUP, PAGEDOWN, END, HOME, LEFT, UP, RIGHT, DOWN, INS, DEL

Overriding built-in keys

You can override built-in behavior by simply registering a hot key with the same name. For example, to submit a form instead of entering a new line when enter is pressed you could do the following:

const enterKeyCommand = {
  str: 'enter',
  run(editor) {
    // submit the form
  },
}
editor.registerKeyCommand(enterKeyCommand)

To fall-back to the default behavior, return false from run.

Responding to text input

The editor exposes a hook onTextInput that can be used to programmatically react to text that the user enters. Specify a handler object with text or match properties and a run callback function, and the editor will invoke the callback when the text before the cursor ends with text or matches match. The callback is called after the matching text has been inserted. It is passed the editor instance and an array of matches (either the result of match.exec on the matching user-entered text, or an array containing only the text).

editor.onTextInput({
  text: 'X',
  run(editor) {
    // This callback is called after user types 'X'
  },
})

editor.onTextInput({
  match: /\d\dX$/, // Note the "$" end anchor
  run(editor) {
    // This callback is called after user types number-number-X
  },
})

The editor has several default text input handlers that are defined in src/js/editor/text-input-handlers.js.

To remove default text input handlers call the unregister function.

editor.unregisterAllTextInputHandlers()

\n special-case match character

When writing a matching string it is common to use \s at the end of a match regex, thus triggering the handler for a given string when the users presses the space or tab key.

When the enter key is pressed no actual characters are added to a document. Instead a new section is created following the current section. Despite this, you may use \n in a match regex to capture moments when the enter key is pressed. For example if you wanted to process a URL for auto-linking you might want to process the string on both the space key and when the user hits enter.

Since \s is a superset of \n, that makes the following regex a valid match for a hand-typed URL after the user presses space or enter:

/\b(https?:\/\/[^\s]+)\s$/

DOM Parsing hooks

A developer can override the default parsing behavior for leaf DOM nodes in pasted HTML.

For example, when an img tag is pasted it may be appropriate to fetch that image, upload it to an authoritative source, and create a specific kind of image card with the new URL in its payload.

A demonstration of this:

function imageToCardParser(node, builder, { addSection, addMarkerable, nodeFinished }) {
  if (node.nodeType !== 1 || node.tagName !== 'IMG') {
    return
  }
  const payload = { src: node.src }
  const cardSection = builder.createCardSection('my-image', payload)
  addSection(cardSection)
  nodeFinished()
}
const options = {
  parserPlugins: [imageToCardParser],
}
const editor = new Mobiledoc.Editor(options)
const element = document.querySelector('#editor')
editor.render(element)

Parser hooks are called with three arguments:

Note that you must call nodeFinished to stop a DOM node from being parsed by the next plugin or the default parser.

Tooltip Plugins

Developers can customize the appearance of tooltips (e.g. those shown when a user hovers over a link element) by specificying a tooltip plugin. A tooltip plugin is an object that implements the renderLink method.

The renderLink method is called with three arguments:

The renderLink method is responsible for populating the passed tooltip element with the correct content to display to the user based on the link in question. This allows Mobiledoc users to, for example, provide localized tooltip text via their system of choice.

const MyTooltip = {
  renderLink(tooltip, link, { editLink }) {
    tooltip.innerHTML = `${i18n.translate('URL: ')} ${link.href}`
    const button = document.createElement('button')
    button.innerText = i18n.translate('Edit')
    button.addEventListener('click', editLink)
    tooltip.append(button)
  },
}
const editor = new Mobiledoc.Editor({
  tooltipPlugin: MyTooltip,
})
const element = document.querySelector('#editor')
editor.render(element)

Contributing

Fork the repo, write a test, make a change, open a PR.

Tests, Linting, Formatting

Install dependencies via yarn:

Run tests via the built-in broccoli server:

Or run headless tests via testem:

Tests in CI are run at Github Actions via Saucelabs (see the test:ci yarn script).

Run linter

Run formatting

Demo

To run the demo site locally:

The assets for the demo are in /demo.

Debugging

A debugging environment that prints useful information about the active Mobiledoc editor can be access by:

Getting Help

If you notice a bug or have a feature request please open an issue on github. If you have a question about usage you can post in the slack channel (automatic invites available from our slackin app) or on StackOverflow using the mobiledoc-kit tag.

Releasing (Implementer notes)

Deploy the website (demo & docs)

The demo website is hosted at bustle.github.io/mobiledoc-kit/demo.

To publish a new version:

Development of Mobiledoc and the supporting libraries was generously funded by BDG Media.