summaryrefslogtreecommitdiff
path: root/doc/development/fe_guide/content_editor.md
blob: 982033cf2ad33521e28acd0c94737e0df314535a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
---
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/product/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 [the Tiptap 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 [the Tiptap 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 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 documentation about
[`parseHTML`](https://tiptap.dev/guide/custom-extensions#parse-html) and
[`addAttributes`](https://tiptap.dev/guide/custom-extensions#attributes) to
learn how to implement them. The Tiptap 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`.