-
Notifications
You must be signed in to change notification settings - Fork 5
Quickstart
We currently support the latest lts and 3.28.
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
ember install @lblod/ember-rdfa-editor@v3
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.
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}} />
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.
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.
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:
- a high level "bundle" that provides a certain feature
- a prosemirror plugin
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.
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.
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.
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.
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.
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.
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;
}Area at the top of the editor. Ideal place to put a 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.
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.
This is the main editing area, and designed to render the editor component.
A vertical "tool" area to the right of the main editing area.
A similar story as the toolbar, essentially a modular version of the old sidebar. It provides the
Collapsible contextual component for grouping related actions.
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.
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.
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.
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.