Content Editor development guidelines
The Content Editor is a UI component that provides a WYSIWYG editing experience for GitLab Flavored Markdown in the GitLab application. It also serves as the foundation for implementing Markdown-focused editors that target other engines, like static site generators.
We use Tiptap 2.0 and ProseMirror
to build the Content Editor. These frameworks provide a level of abstraction on top of
the native
contenteditable
web technology.
Usage guide
Follow these instructions to include the Content Editor in a feature.
Include the Content Editor component
Import the ContentEditor
Vue component. We recommend using asynchronous named imports to
take advantage of caching, as the ContentEditor is a big dependency.
<script>
export default {
components: {
ContentEditor: () =>
import(
/* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue'
),
},
// rest of the component definition
}
</script>
The Content Editor requires two properties:
-
renderMarkdown
is an asynchronous function that returns the response (String) of invoking the Markdown API. -
uploadsPath
is a URL that points to a GitLab upload service withmultipart/form-data
support.
See the WikiForm.vue
component for a production example of these two properties.
Set and get Markdown
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.
Instead, you should obtain an instance of the ContentEditor
class by listening to the
initialized
event:
<script>
import { createAlert } from '~/alert';
import { __ } from '~/locale';
export default {
methods: {
async loadInitialContent(contentEditor) {
this.contentEditor = contentEditor;
try {
await this.contentEditor.setSerializedContent(this.content);
} catch (e) {
createAlert({ message: __('Could not load initial document') });
}
},
submitChanges() {
const markdown = this.contentEditor.getSerializedContent();
},
},
};
</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.
<script>
export default {
data() {
return {
empty: false,
};
},
methods: {
handleContentEditorChange({ empty }) {
this.empty = empty;
}
},
};
</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
dispatch commands to mutate it.
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:
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
We implement node views
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. 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.
<script>
export default {
inject: ['tiptapEditor'],
methods: {
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
.
Learn more about these events in the Tiptap event guide.
<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;
},
},
};
</script>
<template>
<editor-state-observer @error="displayError">
<gl-alert v-if="error" class="gl-mb-6" variant="danger" @dismiss="dismissError">
{{ error }}
</gl-alert>
</editor-state-observer>
</template>
The Tiptap editor object
The Tiptap Editor class manages 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.
Implement new extensions
Extensions are the building blocks of the Content Editor. You can learn how to implement new ones by reading the Tiptap guide. We recommend checking the list of built-in nodes and marks before implementing a new extension from scratch.
Store the Content Editor extensions in the ~/content_editor/extensions
directory.
When using a Tiptap built-in extension, wrap it in a ES6 module inside this directory:
export { Bold as default } from '@tiptap/extension-bold';
Use the extend
method to customize the Extension's behavior:
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:
import Emoji from '../extensions/emoji';
const builtInContentEditorExtensions = [
Code,
CodeBlockHighlight,
Document,
Dropcursor,
Emoji,
// Other extensions
]
The Markdown serializer
The Markdown Serializer transforms a Markdown String to a ProseMirror document 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 by first rendering the Markdown as HTML using the Markdown API endpoint:
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 documentation about
parseHTML
and
addAttributes
to
learn how to implement them. The Tiptap API is a wrapper around ProseMirror's
schema spec API.
Serialization
Serialization is the process of converting a ProseMirror document to Markdown. The Content
Editor uses prosemirror-markdown
to serialize documents. We recommend reading the
MarkdownSerializer
and MarkdownSerializerState
classes documentation before implementing a serializer:
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
.