2021-06-08 14:10:23 -04:00
---
stage: Create
group: Editor
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---
2021-09-09 17:09:36 -04:00
# Content Editor development guidelines **(FREE)**
2021-06-08 14:10:23 -04:00
The Content Editor is a UI component that provides a WYSIWYG editing
2021-09-09 17:09:36 -04:00
experience for [GitLab Flavored Markdown ](../../user/markdown.md ) in the GitLab application.
2021-06-08 14:10:23 -04:00
It also serves as the foundation for implementing Markdown-focused editors
that target other engines, like static site generators.
2021-10-18 11:12:11 -04:00
We use [tiptap 2.0 ](https://tiptap.dev/ ) and [ProseMirror ](https://prosemirror.net/ )
2021-06-08 14:10:23 -04:00
to build the Content Editor. These frameworks provide a level of abstraction on top of
the native
[`contenteditable` ](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content ) web technology.
2021-09-09 17:09:36 -04:00
## Usage guide
2021-06-08 14:10:23 -04:00
2021-09-09 17:09:36 -04:00
Follow these instructions to include the Content Editor in a feature.
2021-06-08 14:10:23 -04:00
2021-09-09 17:09:36 -04:00
1. [Include the Content Editor component ](#include-the-content-editor-component ).
1. [Set and get Markdown ](#set-and-get-markdown ).
1. [Listen for changes ](#listen-for-changes ).
2021-06-08 14:10:23 -04:00
2021-09-09 17:09:36 -04:00
### Include the Content Editor component
2021-06-08 14:10:23 -04:00
2021-09-09 17:09:36 -04:00
Import the `ContentEditor` Vue component. We recommend using asynchronous named imports to
take advantage of caching, as the ContentEditor is a big dependency.
2021-06-08 14:10:23 -04:00
2021-09-09 17:09:36 -04:00
```html
< script >
export default {
components: {
ContentEditor: () =>
import(
/* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue'
),
},
// rest of the component definition
}
< / script >
```
2021-06-08 14:10:23 -04:00
2021-09-09 17:09:36 -04:00
The Content Editor requires two properties:
2021-06-08 14:10:23 -04:00
2021-09-09 17:09:36 -04:00
- `renderMarkdown` is an asynchronous function that returns the response (String) of invoking the
[Markdown API ](../../api/markdown.md ).
2022-04-25 11:08:44 -04:00
- `uploadsPath` is a URL that points to a [GitLab upload service ](../uploads/index.md )
2021-09-09 17:09:36 -04:00
with `multipart/form-data` support.
2021-06-08 14:10:23 -04:00
2021-09-09 17:09:36 -04:00
See the [`WikiForm.vue` ](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue#L207 )
component for a production example of these two properties.
2021-06-08 14:10:23 -04:00
2021-09-09 17:09:36 -04:00
### Set and get Markdown
2021-06-08 14:10:23 -04:00
2021-09-09 17:09:36 -04:00
The `ContentEditor` Vue component doesn't implement Vue data binding flow (`v-model`)
because setting and getting Markdown are expensive operations. Data binding would
trigger these operations every time the user interacts with the component.
2021-06-08 14:10:23 -04:00
2021-09-09 17:09:36 -04:00
Instead, you should obtain an instance of the `ContentEditor` class by listening to the
`initialized` event:
2021-06-08 14:10:23 -04:00
```html
< script >
import createFlash from '~/flash';
2021-09-09 17:09:36 -04:00
import { __ } from '~/locale';
2021-06-08 14:10:23 -04:00
export default {
2021-09-09 17:09:36 -04:00
methods: {
async loadInitialContent(contentEditor) {
this.contentEditor = contentEditor;
try {
await this.contentEditor.setSerializedContent(this.content);
} catch (e) {
createFlash(__('Could not load initial document'));
}
},
submitChanges() {
const markdown = this.contentEditor.getSerializedContent();
},
2021-06-08 14:10:23 -04:00
},
2021-09-09 17:09:36 -04:00
};
< / script >
< template >
< content-editor
:render-markdown="renderMarkdown"
:uploads-path="pageInfo.uploadsPath"
@initialized ="loadInitialContent"
/>
< / template >
```
### Listen for changes
You can still react to changes in the Content Editor. Reacting to changes helps
you know if the document is empty or dirty. Use the `@change` event handler for
this purpose.
```html
< script >
export default {
2021-06-08 14:10:23 -04:00
data() {
return {
2021-09-09 17:09:36 -04:00
empty: false,
};
2021-06-08 14:10:23 -04:00
},
2021-09-09 17:09:36 -04:00
methods: {
handleContentEditorChange({ empty }) {
this.empty = empty;
2021-06-08 14:10:23 -04:00
}
},
2021-09-09 17:09:36 -04:00
};
< / script >
< template >
< div >
< content-editor
:render-markdown="renderMarkdown"
:uploads-path="pageInfo.uploadsPath"
@initialized ="loadInitialContent"
@change ="handleContentEditorChange"
/>
< gl-button :disabled = "empty" @click =" submitChanges " >
{{ __ ('Submit changes') }}
< / gl-button >
< / div >
< / template >
```
## Implementation guide
The Content Editor is composed of three main layers:
- **The editing tools UI**, like the toolbar and the table structure editor. They
display the editor's state and mutate it by dispatching commands.
- **The Tiptap Editor object** manages the editor's state,
and exposes business logic as commands executed by the editing tools UI.
- **The Markdown serializer** transforms a Markdown source string into a ProseMirror
document and vice versa.
### Editing tools UI
The editing tools UI are Vue components that display the editor's state and
2021-10-18 11:12:11 -04:00
dispatch [commands ](https://tiptap.dev/api/commands/#commands ) to mutate it.
2021-09-09 17:09:36 -04:00
They are located in the `~/content_editor/components` directory. For example,
the **Bold** toolbar button displays the editor's state by becoming active when
the user selects bold text. This button also dispatches the `toggleBold` command
to format text as bold:
```mermaid
sequenceDiagram
participant A as Editing tools UI
participant B as Tiptap object
A->>B: queries state/dispatches commands
B--)A: notifies state changes
```
#### Node views
2021-10-18 11:12:11 -04:00
We implement [node views ](https://tiptap.dev/guide/node-views/vue/#node-views-with-vue )
2021-09-09 17:09:36 -04:00
to provide inline editing tools for some content types, like tables and images. Node views
allow separating the presentation of a content type from its
[model ](https://prosemirror.net/docs/guide/#doc.data_structures ). Using a Vue component in
the presentation layer enables sophisticated editing experiences in the Content Editor.
Node views are located in `~/content_editor/components/wrappers` .
#### Dispatch commands
You can inject the Tiptap Editor object to Vue components to dispatch
commands.
NOTE:
Do not implement logic that changes the editor's
state in Vue components. Encapsulate this logic in commands, and dispatch
the command from the component's methods.
```html
< script >
export default {
inject: ['tiptapEditor'],
2021-06-08 14:10:23 -04:00
methods: {
2021-09-09 17:09:36 -04:00
execute() {
//Incorrect
const { state, view } = this.tiptapEditor.state;
const { tr, schema } = state;
tr.addMark(state.selection.from, state.selection.to, null, null, schema.mark('bold'));
// Correct
this.tiptapEditor.chain().toggleBold().focus().run();
},
}
};
< / script >
< template >
```
#### Query editor's state
Use the `EditorStateObserver` renderless component to react to changes in the
editor's state, such as when the document or the selection changes. You can listen to
the following events:
- `docUpdate`
- `selectionUpdate`
- `transaction`
- `focus`
- `blur`
- `error` .
2021-10-18 11:12:11 -04:00
Learn more about these events in [Tiptap's event guide ](https://tiptap.dev/api/events/ ).
2021-09-09 17:09:36 -04:00
```html
< script >
// Parts of the code has been hidden for efficiency
import EditorStateObserver from './editor_state_observer.vue';
export default {
components: {
EditorStateObserver,
},
data() {
return {
error: null,
};
},
methods: {
displayError({ message }) {
this.error = message;
},
dismissError() {
this.error = null;
2021-06-08 14:10:23 -04:00
},
},
};
< / script >
< template >
2021-09-09 17:09:36 -04:00
< editor-state-observer @error =" displayError " >
< gl-alert v-if = "error" class = "gl-mb-6" variant = "danger" @dismiss =" dismissError " >
{{ error }}
< / gl-alert >
< / editor-state-observer >
2021-06-08 14:10:23 -04:00
< / template >
```
2021-09-09 17:09:36 -04:00
### The Tiptap editor object
2021-10-18 11:12:11 -04:00
The Tiptap [Editor ](https://tiptap.dev/api/editor ) class manages
2021-09-09 17:09:36 -04:00
the editor's state and encapsulates all the business logic that powers
the Content Editor. The Content Editor constructs a new instance of this class and
provides all the necessary extensions to support
[GitLab Flavored Markdown ](../../user/markdown.md ).
#### Implement new extensions
Extensions are the building blocks of the Content Editor. You can learn how to implement
2021-10-18 11:12:11 -04:00
new ones by reading [Tiptap's guide ](https://tiptap.dev/guide/custom-extensions ).
We recommend checking the list of built-in [nodes ](https://tiptap.dev/api/nodes ) and
[marks ](https://tiptap.dev/api/marks ) before implementing a new extension
2021-09-09 17:09:36 -04:00
from scratch.
Store the Content Editor extensions in the `~/content_editor/extensions` directory.
When using a Tiptap's built-in extension, wrap it in a ES6 module inside this directory:
```javascript
export { Bold as default } from '@tiptap/extension-bold';
```
Use the `extend` method to customize the Extension's behavior:
```javascript
import { HardBreak } from '@tiptap/extension-hard-break';
export default HardBreak.extend({
addKeyboardShortcuts() {
return {
'Shift-Enter': () => this.editor.commands.setHardBreak(),
};
},
});
```
#### Register extensions
Register the new extension in `~/content_editor/services/create_content_editor.js` . Import
the extension module and add it to the `builtInContentEditorExtensions` array:
```javascript
import Emoji from '../extensions/emoji';
const builtInContentEditorExtensions = [
Code,
CodeBlockHighlight,
Document,
Dropcursor,
Emoji,
// Other extensions
2022-08-08 05:12:15 -04:00
]
2021-09-09 17:09:36 -04:00
```
### The Markdown serializer
The Markdown Serializer transforms a Markdown String to a
[ProseMirror document ](https://prosemirror.net/docs/guide/#doc ) and vice versa.
#### Deserialization
Deserialization is the process of converting Markdown to a ProseMirror document.
We take advantage of ProseMirror's
[HTML parsing and serialization capabilities ](https://prosemirror.net/docs/guide/#schema.serialization_and_parsing )
by first rendering the Markdown as HTML using the [Markdown API endpoint ](../../api/markdown.md ):
```mermaid
sequenceDiagram
participant A as Content Editor
participant E as Tiptap Object
participant B as Markdown Serializer
participant C as Markdown API
participant D as ProseMirror Parser
A->>B: deserialize(markdown)
B->>C: render(markdown)
C-->>B: html
B->>D: to document(html)
D-->>A: document
A->>E: setContent(document)
```
Deserializers live in the extension modules. Read Tiptap's
2021-10-18 11:12:11 -04:00
[parseHTML ](https://tiptap.dev/guide/custom-extensions#parse-html ) and
[addAttributes ](https://tiptap.dev/guide/custom-extensions#attributes ) documentation to
2021-09-09 17:09:36 -04:00
learn how to implement them. Titap's API is a wrapper around ProseMirror's
[schema spec API ](https://prosemirror.net/docs/ref/#model.SchemaSpec ).
#### Serialization
Serialization is the process of converting a ProseMirror document to Markdown. The Content
Editor uses [`prosemirror-markdown` ](https://github.com/ProseMirror/prosemirror-markdown )
to serialize documents. We recommend reading the
[MarkdownSerializer ](https://github.com/ProseMirror/prosemirror-markdown#class-markdownserializer )
and [MarkdownSerializerState ](https://github.com/ProseMirror/prosemirror-markdown#class-markdownserializerstate )
classes documentation before implementing a serializer:
```mermaid
sequenceDiagram
participant A as Content Editor
participant B as Markdown Serializer
participant C as ProseMirror Markdown
A->>B: serialize(document)
B->>C: serialize(document, serializers)
C-->>A: markdown string
```
`prosemirror-markdown` requires implementing a serializer function for each content type supported
by the Content Editor. We implement serializers in `~/content_editor/services/markdown_serializer.js` .