Skip to content

Quickstart

abeforgit edited this page Feb 24, 2023 · 7 revisions

Installation

Supported ember versions

We currently support the latest lts and 3.28.

Prerequisites

The editor currently has a peerdependency on ember-appuniversum and ember-cli-sass. They can be installed as follows:

  • ember install @appuniversum/ember-appuniversum.
  • ember install ember-cli-sass

Installation

ember install @lblod/ember-rdfa-editor@v3

Minimal Setup

The editor is structured as a core component with a suite of plugins. It is rare to run the editor without any plugins, as even basic functionality such as bold/italic and lists are packaged as plugins.

A word about schemas

The only strictly required argument to the basic editor is the schema argument. It defines which types of nodes the editor is aware of and how a document is structured. A minimal functional schema consists of 3 node "specs": a doc node, which defines the root node of the document, a paragraph node, and a text node, which defines, you guessed it, text. The two properties that make up the full schema specification are nodes and marks. While these are simple POJOs, the ordering of the nodes in them does matter. When parsing html, prosemirror matches every incoming html element against the specs, in top-to-bottom order. (There are ways to override this, but they should be used as little as possible.).

// my-editor.ts
import component from "@glimmer/component";
import { schema } from "@lblod/ember-rdfa-editor";
import { doc, paragraph, text } from "@lblod/ember-rdfa-editor/nodes";

interface args {
}

export default class myeditorcomponent extends component<args> {
  schema: schema = new schema({ nodes: {doc, paragraph, text}, marks: {} });
}
{{!--my-editor.hbs--}}

<Editor @schema={{this.schema}} />

Controlling the editor

In almost all cases, you'll need some kind of way to interact with the editor from your javascript code. For this, we use a callback:

// my-editor.ts
import component from "@glimmer/component";
-import { schema } from "@lblod/ember-rdfa-editor";
+import { schema, SayController } from "@lblod/ember-rdfa-editor";
+import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { doc, paragraph, text } from "@lblod/ember-rdfa-editor/nodes";

interface args {}

export default class myeditorcomponent extends component<args> {
  schema: schema = new schema({ nodes: {doc, paragraph, text}, marks: {} })
+ @tracked
+ controller?: SayController
+ @action
+ onEditorInit(controller: SayController) {
+   this.controller = controller;
+ }
}
{{!--my-editor.hbs--}}

-<Editor @schema={{this.schema}} />
+<editor @schema={{this.schema}} rdfaEditorInit={{this.onEditorInit}} />

One of the most common uses of the controller is to get and set the content of the editor.

const content: string = this.controller.htmlContent;
this.controller.setHtmlContent("<p>hello world!</p>")

All properties of the controller are tracked, so you can simply use ember reactivity patterns as normal.

A more complete example

The minimal example demonstrates the capability to start from scratch and build an editor completely to your needs. However, for most applications, it's really a bit too barebones.

For the complete example, we will include all "core" plugins, that is, plugins that are shipped with the editor itself. We will also demonstrate the EditorContainer component, which is a shortcut way to provide a standard layout with a toolbar and a sidebar. However, it's worth noting that nothing here is special, it all uses the same basic interfaces that are available to the host app. This means you can -and are encouraged to- build this layout yourself, make custom nodes, plugins, buttons, form fields, etc.

First, lets look at the basic editor, with all the plugins active:

import Controller from '@ember/controller';
import {
  NodeViewConstructor,
  Plugin,
  SayController,
  Schema,
} from '@lblod/ember-rdfa-editor';
import {
  block_rdfa,
  doc,
  hard_break,
  horizontal_rule,
  invisible_rdfa,
  paragraph,
  repaired_block,
  text,
} from '@lblod/ember-rdfa-editor/nodes';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inline_rdfa } from '@lblod/ember-rdfa-editor/marks';
import {
  em,
  strikethrough,
  strong,
  subscript,
  superscript,
  underline,
} from '@lblod/ember-rdfa-editor/plugins/text-style';
import {
  tableKeymap,
  tableNodes,
  tablePlugin,
} from '@lblod/ember-rdfa-editor/plugins/table';
import { image } from '@lblod/ember-rdfa-editor/plugins/image';
import { link, linkView } from '@lblod/ember-rdfa-editor/plugins/link';
import { blockquote } from '@lblod/ember-rdfa-editor/plugins/blockquote';
import { heading } from '@lblod/ember-rdfa-editor/plugins/heading';
import { code_block } from '@lblod/ember-rdfa-editor/plugins/code';
import {
  bullet_list,
  list_item,
  ordered_list,
} from '@lblod/ember-rdfa-editor/plugins/list';
import { placeholder } from '@lblod/ember-rdfa-editor/plugins/placeholder';

export default class AppliationController extends Controller {
  @tracked controller?: SayController;

  @tracked plugins: Plugin[] = [tablePlugin, tableKeymap];
  @tracked nodeViews: (
    controller: SayController
  ) => Record<string, NodeViewConstructor> = (controller: SayController) => {
    return {
      link: linkView(this.linkOptions)(controller),
    };
  };

  get schema() {
    return new Schema({
      nodes: {
        doc,
        paragraph,

        repaired_block

        list_item,
        ordered_list,
        bullet_list,
        placeholder,
        ...tableNodes({ tableGroup: 'block', cellContent: 'block+' }),
        heading,
        blockquote,

        horizontal_rule,
        code_block,

        text,

        image,

        hard_break,
        invisible_rdfa,
        block_rdfa,
        link: link(this.linkOptions),
      },
      marks: {
        inline_rdfa,
        em,
        strong,
        underline,
        strikethrough,
        subscript,
        superscript,
      },
    });
  }

  get linkOptions() {
    return {
      interactive: true,
    };
  }

  @action
  rdfaEditorInit(controller: SayController) {
    this.controller = controller;
    this.controller.setHtmlContent("<p>hello world</p>");
  }
}
{{!--my-editor.hbs--}}

<Editor @schema={{this.schema}}
        @rdfaEditorInit={{this.rdfaEditorInit}}
        @plugins={{this.plugins}}
        @nodeViews={{this.nodeViews}}/>

It looks intimidating, but we've deliberately gone light on the syntactic sugar to provide the host app with as much flexibility as possible. And really, it's not that bad.

First, lets look at the 2 new arguments.

Plugins

The term "plugin" has historically been, and unfortunately still is, a bit overloaded when it comes to the editor. In essence there are 2 definitions:

So far, we've been using the first definition. Take the table plugin for example. We import it like this:

import {
  tableKeymap,
  tableNodes,
  tablePlugin,
} from '@lblod/ember-rdfa-editor/plugins/table';

These three elements combined provide the full table feature. Of these three things, the tableKeymap and the tablePlugin are actual Prosemirror Plugins.

In the case of the table plugin, these Plugin instances are exported directly. Some plugins might export constructors instead, allowing them to take in configuration arguments. Every plugin will provide its own documentation on how to import it and set it up.

Nodeviews

The term NodeView once again comes directly from Prosemirror. They provide a way to directly manage the View (= DOM representation) of a certain node. This is mainly used to provide special UI and controls within the text.

The details of these concepts are only relevant when the need arises to create your own plugins. For general use, it's a matter of importing everything from the plugin bundle and putting the lego blocks in the right places.

Schema

We've already encountered the schema, but here it's taken a more realistic form. For most nodes, it's easy to see why they're included. But this also demonstrates that the schema is exhaustive and explicit. When the editor parses an HTML element that doesn't correspond to any node defined in the schema, it will skip it (but will continue parsing its content, meaning that at the very least all text information is kept, since a schema must always have a text node). To learn more about node definitions, which are called NodeSpecs, go here.

With this in mind, we can understand some of the less intuitive nodes the editor provides.

block_rdfa, inline_rdfa and invisible_rdfa

In traditional wysiwyg documents, html elements, with very few exceptions, are really only used to manipulate the visible output to what the user expects. This means that most editors can actually get by with a very minimal subset of html. For example: we could normalize deeply nested tree div elements to a visually equivalent list of adjactend p elements, which are much easier to manage. However, since the say-editor explicitly supports rdfa-enriched html, we need some way to explicitly preserve any structure and nesting that is semantically significant. While in a lot of applications we achieve this by defining custom nodes for rdfa that we expect and want to provide tooling for, we need a "catchall" to make sure that any rdfa we do not explicitly manage is still at least preserved as-is.

repaired_block

The HTML spec defines explicit rules for the allowed types of content in every html element. Unfortunately, it is extremely common to ignore these rules, and browsers are incredibly good at rendering "erroneous" html. The repaired_block node takes care of the most relevant case of non-compliant html: a non-phrasing node inside a node that only allows phrasing content. In practice, this is typically a "block" node, (such as a div, or a p) inside an "inline" node (such as a span). "block"-ness and "inline"-ness are incredibly important concepts for the Prosemirror editing engine, and as such must be explicitly managed. The repaired_block node captures erroneous block elements inside inline elements, and converts them to span elements with their attributes preserved.

All the bells and whistles

At present we provide one last layer of convenience on top of what we've described above: the EditorContainer component.

{{!--my-editor.hbs--}}
<EditorContainer>
    <:top>
        <Toolbar as |Tb|>
            <Tb.Group>
                <Plugins::History::Undo @controller={{this.controller}}/>
                <Plugins::History::Redo @controller={{this.controller}}/>
            </Tb.Group>
            <Tb.Group>
                <Plugins::TextStyle::Bold @controller={{this.controller}}/>
                <Plugins::TextStyle::Italic @controller={{this.controller}}/>
                <Plugins::TextStyle::Strikethrough @controller={{this.controller}}/>
                <Plugins::TextStyle::Underline @controller={{this.controller}}/>
                <Plugins::TextStyle::Subscript @controller={{this.controller}}/>
                <Plugins::TextStyle::Superscript @controller={{this.controller}}/>
            </Tb.Group>
            <Tb.Group>
                <Plugins::List::Unordered @controller={{this.controller}}/>
                <Plugins::List::Ordered @controller={{this.controller}}/>
                <Plugins::List::IndentationControls @controller={{this.controller}}/>
            </Tb.Group>
            <Tb.Group>
                <Plugins::Link::LinkMenu @controller={{this.controller}}/>
            </Tb.Group>
            <Tb.Group>
                <Plugins::Table::TableMenu @controller={{this.controller}}/>
            </Tb.Group>
            <Tb.Group>
                <Plugins::Heading::HeadingMenu @controller={{this.controller}}/>
            </Tb.Group>
            <Tb.Spacer />
            <Tb.Group>
                <Plugins::RdfaBlockRender::RdfaBlocksToggle @controller={{this.controller}}/>
            </Tb.Group>
        </Toolbar>
    </:top>

    <:default>
        <Editor @schema={{this.schema}}
                @plugins={{this.plugins}}
                @nodeViews={{this.nodeViews}}
                @rdfaEditorInit={{this.rdfaEditorInit}}
        />
    </:default>
    <:aside>
        <Sidebar as |Sb|>
            <Sb.Collapsible @title="Hello!">
                <button type="button"
                    {{on "click" this.insertHelloWorld}}>
                    Hello World!
                </button>

            </Sb.Collapsible>
            <Plugins::Link::LinkEditor @controller={{this.controller}}/>
        </Sidebar>
    </:aside>
</EditorContainer>

Let's go top to bottom.

EditorContainer

This component doesn't do much more than provide the scaffolding for the three main blocks: the toolbar, the main editing area, and the sidebar. If you were familiar with the editor before version 3.0.0, this is effectively the equivalent of the RdfaEditor component, but instead of passing everything through component arguments, we now rely on ember composition features like contextual components and named blocks. It takes the following arguments, which mainly control some css classes:

interface EditorContainerArgs {
  editorOptions?: EditorOptions;
  /**
   * Show bouncing dots instead of the :default block
   */
  loading?: boolean;

  /**
   * Show a border around elements with rdfa.
   * Mainly for informative purposes as it greatly disturbs the visual structure
   * Meant to be controlled by some sort of toggle.
   */
  showRdfaBlocks?: boolean;
  
}
interface EditorOptions {
  /**
   * Whether or not to style the editing area as an A4-ish page
   */
  showPaper?: boolean;
  /**
   * Moves the toolbar to the bottom of the page, with css tricks
   */
  showToolbarBottom?: boolean;
  /**
   * Shows a left sidebar with information of rdfa context of the element under the cursor
   * At the time of writing, it is unclear how these three options exactly interact as they are carried over from 
   * the before-time, and will likely be reworked.
   */
  showRdfaHover?: boolean;
  showRdfa?: boolean;
  showRdfaHighlight?: boolean;
}

:top

Area at the top of the editor. Ideal place to put a toolbar.

Toolbar

We no longer impose our toolbar design on every consuming app, but we do still provide it, in a more flexible form than ever before. It provides the Group, Spacer and Divider contextual components. A Divider is a vertical line for visual separation. A Group is meant for a logical group of related controls, and will render a Divider automatically when adjacent to other groups. A Spacer is simply an element with width set to 100%, which in the flex context of the toolbar means it functions as a sort of space-eater, pushing adjacent items away.

Plugin UI

Here we can also see what used to be called "widgets". These are essentially nothing more than some convenient UI provided by plugins so you don't have to make it yourself. In practice, and especially for plugins in the @lblod/ember-rdfa-editor-lblod-plugins project, these widgets can be very complex and house entire CRUD apps within them.

:default

This is the main editing area, and designed to render the editor component.

:aside

A vertical "tool" area to the right of the main editing area.

Sidebar

A similar story as the toolbar, essentially a modular version of the old sidebar. It provides the Collapsible contextual component for grouping related actions.

Other building blocks

Not shown here, but most of the buttons provided by the plugins are simply prewired versions of Toolbar::Button and Toolbar::Dropdown components. These are also exported to be used as a quick way to hook up extra logic while maintaining a consistent look with the rest of the toolbar.

disclaimer

We are aware the current design of this component is suboptimal. For instance: except in very early initialization, a controller will always be available. However, we are forced to mark it as optional, and indeed, quite a few components need to take into account that controller access is not guaranteed. Given that only happens once in the lifecycle, this is cumbersome. We're aware of this, and will likely improve the developer experience in following iterations.

Commands

The astute reader will have noticed a hitherto unmentioned method being referenced in the above template. Here's its definition:

// to be added to my-editor.ts
import { insertHtml } from "@lblod/ember-rdfa-editor/commands";

export default class MyEditor extends Component<Args> {
  @action
  insertHelloWorld() {
    if (!this.controller) {
      return;
    }
    const { to, from } = this.controller.activeEditorState.selection;
    this.controller.focus();
    this.controller.doCommand(insertHtml("<p>hello world</p>", to, from));
  }

}

With this, we demonstrate the most common way to execute editing actions programmatically: through the use of commands. Commands are simply functions which take in a State and, optionally, a dispatch function and a reference to the current SayView instance. They return true when they were succesful, and false when not. The aforementioned insertHtml function is actually a higher-order function returning a command function, which is the standard way of providing arguments to commands. Many commands simply take no arguments and are exported as-is, in which case they typically operate on the current selection.

Besides doCommand, which, you know, does the thing, there is also checkCommand. This is essentially a "dry-run" mode, where the command will report success or failure without actually executing. This is extremely useful, and is how the bold/italic/underline/etc buttons calculate their active state.

The call to controller.focus is very commonly seen on buttons, since the click steals away the focus, and typically users expect to be able to continue typing immediately after inserting something.

Going deeper

If the provided commands are insufficient, you'll need to drop one level of abstraction lower, and use transforms. This is the heart of the editing engine, and allows you to do literally anything prosemirror is capable of. It is also what commands use to do their thing: commands are simply conveniently wrapped functions that use transforms do change the document. Beside the convention for their return value, and their ability to "dry-run", they are nothing special.

The primary way we do transformations is as follows:

  • start a transaction, using this.controller.activeEditorState.tr,
  • use the transaction's methods to transform the state
  • dispatch the transaction to "apply" and make the view update, using this.controller.activeView.dispatch(transaction)

There is technically an even lower level of abstraction called Steps, but even in say-editor internals we haven't needed them yet.

We provide a more convenient interface for this flow:

this.controller.withTransaction(tr => {
  // do stuff
  return tr;
});

which abstracts away the creation and dispatch of the transaction.

In practice, we've found that this is really best kept for simple cases where creating a command feels like overkill. An example from the implementation of the link plugin:

function removeLink() {
  this.controller.withTransaction(
    (tr) => {
      return tr.replaceWith(
        this.pos,
        this.pos + this.node.nodeSize,
        this.node.content
      );
    },
    { view: this.controller.mainEditorView }
  );
}

We recommend creating commands for more complex occasions. Once withTransaction blocks become complex, the temptation to reuse the controller grows, and while that's not explicitly disallowed, it becomes very confusing. tldr: don't do this:

  this.controller.withTransaction(
  (tr) => {
    // not always a problem, but not a great idea nonetheless
    this.controller.doCommand(somethingElse);
    return tr.replaceWith(
      this.pos,
      this.pos + this.node.nodeSize,
      this.node.content
    );
  }
);

Reusing commands within other commands is also less intuitive than we'd like, and we're looking to improve that in the future.