gitlab-org--gitlab-foss/doc/development/fe_guide/content_editor.md

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

355 lines
11 KiB
Markdown
Raw Normal View History

---
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
---
# Content Editor development guidelines **(FREE)**
The Content Editor is a UI component that provides a WYSIWYG editing
experience for [GitLab Flavored Markdown](../../user/markdown.md) 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](https://tiptap.dev/) and [ProseMirror](https://prosemirror.net/)
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.
## Usage guide
Follow these instructions to include the Content Editor in a feature.
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).
### 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.
```html
<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](../../api/markdown.md).
- `uploadsPath` is a URL that points to a [GitLab upload service](../uploads/index.md)
with `multipart/form-data` support.
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.
### 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:
```html
<script>
import createFlash from '~/flash';
import { __ } from '~/locale';
export default {
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();
},
},
};
</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 {
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](https://tiptap.dev/api/commands/#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:
```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
We implement [node views](https://tiptap.dev/guide/node-views/vue/#node-views-with-vue)
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'],
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 [Tiptap's event guide](https://tiptap.dev/api/events/).
```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;
},
},
};
</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](https://tiptap.dev/api/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](../../user/markdown.md).
#### Implement new extensions
Extensions are the building blocks of the Content Editor. You can learn how to implement
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
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
```
### 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
[parseHTML](https://tiptap.dev/guide/custom-extensions#parse-html) and
[addAttributes](https://tiptap.dev/guide/custom-extensions#attributes) documentation to
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`.