WYSIWYG editor toolkit architecture proposal
Goal
- Provide a toolkit for building a WYSIWYG editing experience for Markdown content across GitLab features.
- Provide a strong foundation for advanced editors like the Static Site Editor.
Quality attributes
We’ve identified the following characteristics as desirable for this architecture:
Performance
Editing Markdown content happens in pages where load time is of critical importance like the Merge Request and Issue pages. The architecture should promote bundle size efficiency, a robust caching strategy, and asynchronous loading.
Modularity
We understand that every use case in GitLab that benefits from a Markdown WYSIWYG editor is slightly different. For example, the Static Site Editor needs to display “markdown partials” referenced within markdown pages while the issue description editor does not.
For this reason, we are not proposing a monolithic editor that is easily dropped into the aforementioned features. That approach is inflexible and also affects performance. Our purpose is providing a set of essential components that are easy to combine to achieve the desired editing experience in a time-efficient way. We can promote feature-specific modules to the editor toolkit when we discover common patterns across GitLab.
Extensibility
In the same vein, the essential components provided by the WYSIWYG editor toolkit should be easy to extend or replace. We prefer building an editor toolkit that is easy to evolve rather than covering all possible use cases upfront. We will avoid creating unnecessary abstractions (in the form of APIs) that usually target specific features. Instead, our focus is finding the right balance between flexibility and productivity.
Accessibility
The WYSIWYG editor toolkit will adhere to the Pajamas Design System that has a focus on accessibility. We will also make sure that every visual component has internationalization support.
Dependencies
After investigating several platforms for building WYSIWYG, we’ve decided to use the following tools. For more information about the investigation that led to these decision, read #231725 (comment 430630089).
ProseMirror
ProseMirror is a set of tools and concepts for building WYSIWYG editors. It provides a a stable, proven, and well-tested approach for building a WYSIWYG editing experience for different types of content. It also enables us to support advanced use cases like collaborative editing.
TipTap
TipTap is a layer on top of ProseMirror that provides the following benefits:
- Renderless components that increase our productivity for implementing user experiences like block-based editors (Medium, Notion).
- Allows using Vue.js for implementing UI components that display and edits a document.
- A friendly API.
Markdownit
Markdown-it is a Markdown parser and renderer (to HTML by default). Markdown-it will allow us to parse a Markdown document and produce an Abstract Syntax Tree.
ProseMirror-Markdown
prosemirror-markdown is a set of utilities to convert a blob of Markdown to a ProseMirror document model. It consumes markdown-it’s Abstract Syntax Tree.
Internal components
The following diagram demonstrates how GitLab’s WYSIWYG editor toolkit interacts with the dependencies listed above.
Markdown document schema
The schema is a set of rules that specify what elements are valid in a document and how they can be arranged. By default, GitLab’s markdown document schema will support all the elements supported by the Commonmark and GFM specifications like lists, blockquotes, tables, lists, strikestrough, etc. The schema is also the basis to indicate how to convert a piece of HTML or Markdown from the clipboard to the ProseMirror document model.
Markdown to document model converter
The converter specifies how to map raw Markdown content to a ProseMirror document model. When the Markdown parser converts raw markdown content to an Abstract Syntax Tree, we need to indicate how to convert that data structure into a ProseMirror document that satisfies the schema’s rules. In other words, this component will serve as the bridge between the Markdown parser (markdown-it) and ProseMiror. We will extend the prosemirror-markdown library to support an extended Markdown syntax like GFM.
UI Toolkit
TipTap does not provide UI components like a button to set a text as bold, or a pop-up toolbar for editing links. Instead, it provides renderless components that allows to easily implement a UI for those actions using Vue.js. The UI toolkit is a set of UI components based on Pajamas components and GitLab UI utility classes. Combining TipTap with GitLab UI capabilities will allow us to make significant progress quickly.
Editor Extensions
Features like the Wiki and the Static Site Editor have different requirements for editing Markdown and support different syntax extensions. Each application will be able to provide syntax extensions that satisfy those requirements. This helps to avoid loading unnecessary components and increase the bundle size. The following code snippet shows how the Static Site Editor could add extensions for supporting ERB snippets and frontmatter in Markdown documents:
// Static Site Editor
import { fromMarkdown, gfmEditor } from `@gitlab/ui/rich-text-editor`;
const editor = gfmEditor.build([
new FrontmatterCodeBlock(),
new TemplateSyntaxCodeBlock(),
]);
const document = fromMarkdown({
content,
schema: editor.schema,
extensions: [
erbSnippetsParser,
frontmatterParser,
],
});
Markdown preview endpoint integration
As indicated previously, GitLab’s features like the Wiki and Issues use custom Markdown syntax extensions. Implementing client-side support to parse and render this custom syntax is very expensive. We can avoid this cost altogether by relying on GitLab’s Markdown API to render GitLab Flavored Markdown.
The way to do this is by creating a ProseMirror schema that knows how to parse the HTML produced by the Markdown API endpoint. This approach is better because creating this schema is much much cheaper than implementing a parser extension. ProseMirror will load the HTML produced by the Markdown endpoint and make it editable.
Once the user applies the desired changes, the WYSIWYG editor will convert the document back to GitLab Flavored Markdown by using the ProseMirror-Markdown dependency. The following sequence diagrams describe this process visually for the hypothetical scenario where the Wiki uses the WYSIWYG editor:
Loading HTML from the Markdown Endpoint
sequenceDiagram
Wiki->>+Markdown Endpoint: Render markdown
Markdown Endpoint-->>+Wiki: HTML output
Wiki->>-WYSIWYG: Load HTML output
Producing GitLab Flavored Markdown
sequenceDiagram
Wiki->>+WYSIWYG: Get content
WYSIWYG-->>-Wiki: Document
WYSIWYG->>+ProseMirror Markdown: Convert document to markdown
ProseMirror Markdown-->>+WYSIWYG: Markdown output
Technical details
These are some technical details collected over our investigation about WYSIWYG editor platforms.
Bundle size
Dependency name | Optimized bundle size (kb) | Gzipped size (kb) |
---|---|---|
Prosemirror dependencies | 378.22 | 93.13 |
TipTap dependencies | 190.85 | 48.9 |
Markdown-it dependencies | 101 | 28.18 |
Total | 597.25 | 170.21 |
Proof of concept
gitlab-ui!1795 (closed) contains a proof of concept that demonstrates the design decisions explained above using a concrete example. The merge request is heavily annotated and explains how each module in the source code relates to the architecture’s components.
Instrumentation
Instrumentation is of upmost importance to define a success criteria based on the editor’s real life usage. We want to instrument the following events:
- Applying formatting like bold, italics, header, etc.
- Inserting and editing complex objects like tables.
- Inserting media objects like media and videos.
- Which features are using the WYSIWYG editor.
To track events that change the editor’s content (the first three items in the list), we will rely on prosemirror transactions. Each transaction represents an atomic action that, when applied to a document, produces a new document.
Known risks and mitigation strategies
We’ve identified these risks so far:
Page load impact
A bundle size of 160kb
gzipped will have a significant impact in page load. In features where a WYSIWYG editor is just a small component of the whole user experience like the Merge Request and Issue pages, adding 160kb is not an option. In features where the WYSIWYG editor represents the main workflow like the Wiki page editor and the Static Site Editor, loading these dependencies on page load can be considered. These are some measures we can take to keep page load and bundle size under control:
- Lazy loading We can load a read-only view of a content and only load the editor when the user requests to edit the content. We can also load a raw markdown editor by default and allow the user to opt-in for the WYSIWYG experience.
- Service workers We can set up workbox to pre-fetch and cache critical dependencies like the WYSIWYG editor.
- By relying on GitLab UI components and utility classes, we won’t load extra CSS for styling the editor.
Dependency coupling and external change
For advanced use cases like extending the Markdown parser for supporting a custom syntax, it is difficult to avoid coupling our components to the parser’s API. Every parser produces a different Abstract Syntax Tree and creating an abstraction over these data structures is too expensive. In the same vein, extending ProseMirror modules also requires using concepts unique to that platform such as a Domain Specific Language to define the document’s schema.
We can mitigate this risk by carefully identifying those coupling points and encapsulating them in modules. Then we can rely on integration tests to detect early when a breaking change is introduced.