From 2cc2bd29833c722a60a20092c160b82494e21ced Mon Sep 17 00:00:00 2001 From: as-op Date: Tue, 6 Oct 2026 12:05:00 +0200 Subject: [PATCH] inline up-to-date mermaid plugin fork --- src/icons/mermaid-info.svg | 1 + src/icons/mermaid-insert.svg | 1 + src/icons/mermaid-preview-mode.svg | 1 + src/icons/mermaid-source-mode.svg | 1 + src/icons/mermaid-split-mode.svg | 1 + src/op-ckeditor.js | 2 + src/op-plugins.js | 3 + src/plugins/mermaid/LICENSE.md | 22 ++ src/plugins/mermaid/README.md | 53 +++ src/plugins/mermaid/insert_mermaid_command.js | 39 +++ src/plugins/mermaid/mermaid.js | 28 ++ src/plugins/mermaid/mermaid_editing.js | 301 ++++++++++++++++++ .../mermaid/mermaid_preview_command.js | 39 +++ .../mermaid/mermaid_source_view_command.js | 39 +++ .../mermaid/mermaid_split_view_command.js | 39 +++ src/plugins/mermaid/mermaid_toolbar.js | 34 ++ src/plugins/mermaid/mermaid_ui.js | 177 ++++++++++ src/plugins/mermaid/utils.js | 30 ++ src/theme/mermaid.css | 71 +++++ .../mermaid/insert-mermaid-command.test.js | 70 ++++ tests/plugins/mermaid/mermaid-editing.test.js | 184 +++++++++++ tests/plugins/mermaid/utils.test.js | 79 +++++ webpack.config.js | 28 +- 23 files changed, 1234 insertions(+), 9 deletions(-) create mode 100644 src/icons/mermaid-info.svg create mode 100644 src/icons/mermaid-insert.svg create mode 100644 src/icons/mermaid-preview-mode.svg create mode 100644 src/icons/mermaid-source-mode.svg create mode 100644 src/icons/mermaid-split-mode.svg create mode 100644 src/plugins/mermaid/LICENSE.md create mode 100644 src/plugins/mermaid/README.md create mode 100644 src/plugins/mermaid/insert_mermaid_command.js create mode 100644 src/plugins/mermaid/mermaid.js create mode 100644 src/plugins/mermaid/mermaid_editing.js create mode 100644 src/plugins/mermaid/mermaid_preview_command.js create mode 100644 src/plugins/mermaid/mermaid_source_view_command.js create mode 100644 src/plugins/mermaid/mermaid_split_view_command.js create mode 100644 src/plugins/mermaid/mermaid_toolbar.js create mode 100644 src/plugins/mermaid/mermaid_ui.js create mode 100644 src/plugins/mermaid/utils.js create mode 100644 src/theme/mermaid.css create mode 100644 tests/plugins/mermaid/insert-mermaid-command.test.js create mode 100644 tests/plugins/mermaid/mermaid-editing.test.js create mode 100644 tests/plugins/mermaid/utils.test.js diff --git a/src/icons/mermaid-info.svg b/src/icons/mermaid-info.svg new file mode 100644 index 0000000..c9614b2 --- /dev/null +++ b/src/icons/mermaid-info.svg @@ -0,0 +1 @@ + diff --git a/src/icons/mermaid-insert.svg b/src/icons/mermaid-insert.svg new file mode 100644 index 0000000..b87e06b --- /dev/null +++ b/src/icons/mermaid-insert.svg @@ -0,0 +1 @@ + diff --git a/src/icons/mermaid-preview-mode.svg b/src/icons/mermaid-preview-mode.svg new file mode 100644 index 0000000..cdccfca --- /dev/null +++ b/src/icons/mermaid-preview-mode.svg @@ -0,0 +1 @@ + diff --git a/src/icons/mermaid-source-mode.svg b/src/icons/mermaid-source-mode.svg new file mode 100644 index 0000000..be4647c --- /dev/null +++ b/src/icons/mermaid-source-mode.svg @@ -0,0 +1 @@ + diff --git a/src/icons/mermaid-split-mode.svg b/src/icons/mermaid-split-mode.svg new file mode 100644 index 0000000..ef07ec1 --- /dev/null +++ b/src/icons/mermaid-split-mode.svg @@ -0,0 +1 @@ + diff --git a/src/op-ckeditor.js b/src/op-ckeditor.js index 909fef6..0560938 100644 --- a/src/op-ckeditor.js +++ b/src/op-ckeditor.js @@ -32,6 +32,7 @@ FullEditor.defaultConfig.toolbar = { 'todoList', 'imageUpload', 'blockQuote', + 'mermaid', '|', 'insertTable', 'macroList', @@ -64,6 +65,7 @@ ConstrainedEditor.defaultConfig.toolbar = { 'todoList', 'imageUpload', 'blockQuote', + 'mermaid', '|', 'opContentRevisions', 'undo', diff --git a/src/op-plugins.js b/src/op-plugins.js index 7ecff74..3bc5cf7 100644 --- a/src/op-plugins.js +++ b/src/op-plugins.js @@ -46,6 +46,7 @@ import OpMacroWikiPageLinkPlugin from "./plugins/op-macro-wiki-page-link/op-macr import OpMacroWikiPageLinkAddExisting from "./plugins/op-macro-wiki-page-link/op-macro-wiki-page-link-add-existing"; import OpMacroWikiPageLinkCreateNew from "./plugins/op-macro-wiki-page-link/op-macro-wiki-page-link-create-new"; import OPResizerGuard from "./plugins/op-resizer-guard/op-resizer-guard-plugin"; +import Mermaid from './plugins/mermaid/mermaid'; // We divide our plugins into separate concerns here // in order to enable / disable each group by configuration @@ -109,6 +110,8 @@ export const builtinPlugins = [ OPMacroListPlugin, + Mermaid, + OpCustomCssClassesPlugin, ].concat( // OpenProject Macro plugin group diff --git a/src/plugins/mermaid/LICENSE.md b/src/plugins/mermaid/LICENSE.md new file mode 100644 index 0000000..f2b8704 --- /dev/null +++ b/src/plugins/mermaid/LICENSE.md @@ -0,0 +1,22 @@ +Software License Agreement +========================== + +**CKEditor 5 mermaid feature** (https://ckeditor.com/ckeditor-5/)
+Copyright (c) 2003-2022, [CKSource](http://cksource.com) Holding sp. z o.o. All rights reserved. + +Licensed under the terms of [GNU General Public License Version 2 or later](http://www.gnu.org/licenses/gpl.html). + +Sources of Intellectual Property Included in CKEditor 5 mermaid feature +--------------------------------------------------------------------- + +Where not otherwise indicated, all CKEditor 5 mermaid feature content is authored by CKSource engineers and consists of CKSource-owned intellectual property. + +The following libraries are included in CKEditor 5 mermaid feature under the [MIT license](https://opensource.org/licenses/MIT): + +* Lo-Dash - Copyright (c) JS Foundation and other contributors https://js.foundation/. Based on Underscore.js, copyright Jeremy Ashkenas. +* mermaid - Copyright (c) 2014 - 2021 Knut Sveidqvist. (MIT License) + +Trademarks +---------- + +**CKEditor** is a trademark of [CKSource](http://cksource.com) Holding sp. z o.o. All other brand and product names are trademarks, registered trademarks or service marks of their respective holders. diff --git a/src/plugins/mermaid/README.md b/src/plugins/mermaid/README.md new file mode 100644 index 0000000..1ad8746 --- /dev/null +++ b/src/plugins/mermaid/README.md @@ -0,0 +1,53 @@ +# Mermaid diagrams + +A `mermaid` block widget holding diagram source, shown as a source textarea, a rendered preview, or +both side by side. Load the `Mermaid` glue plugin; it pulls in `MermaidEditing`, `MermaidToolbar` +and `MermaidUI`. + +| File | Role | +|---|---| +| `mermaid.js` | Glue plugin | +| `mermaid_editing.js` | Schema, conversion, and the renderer (`renderMermaid`) | +| `mermaid_ui.js` | The insert button with its optional template dropdown, the mode buttons, the info button | +| `mermaid_toolbar.js` | Registers the balloon toolbar shown when a diagram is selected | +| `insert_mermaid_command.js` | Inserts a blank or pre-filled diagram | +| `mermaid_{preview,source_view,split_view}_command.js` | Switch the widget's `displayMode` | +| `utils.js` | `debounce` for the source textarea, `checkIsOn` for button state | + +## The mermaid library is not a dependency + +The plugin never imports mermaid - bundling it pulls in d3, cytoscape and katex, which pushed this +build's Terser stage past ten minutes. The host passes the library in through editor config: + +```js +mermaid: { + lazyLoad: () => import( 'mermaid' ).then( m => m.default ), + config: { /* mermaid initialize() config */ }, + samples: [ /* { name, content } diagram templates for the insert dropdown */ ], + openHelp: () => { /* opens https://mermaid.js.org/intro/ when not given */ } +} +``` + +`renderMermaid` calls `lazyLoad` once, memoises the promise, and calls `initialize()` on the +resolved instance. Without it the widget still edits and round-trips its source; only the preview +stays empty. Renders are generation-stamped so a slow render that has been superseded cannot +overwrite a newer one, and a failed render shows the error message in place. + +## Round-trip + +A diagram is content, not markup: it round-trips as a fenced `mermaid` code block, upcast from +`pre > code.language-mermaid` and downcast back to it. The upcast runs on `element:pre` at +`highest` priority because this build's own code block plugin claims every `
` on `high` and
+consumes the `` inside it without dispatching it.
+
+Upstream also persists the picked display mode on the `` element. That is dropped here: the
+data format is CommonMark, so an attribute on `` never survives the round trip and every
+diagram opens in split mode.
+
+## Provenance
+
+Vendored from [Trilium](https://github.com/TriliumNext/Trilium)'s
+`packages/ckeditor5/src/plugins/mermaid`, itself derived from CKSource's
+`@ckeditor/ckeditor5-mermaid`, copyright CKSource Holding sp. z o.o., used under the
+GPL-2.0-or-later arm of its license - see `LICENSE.md` next to this file. Converted from TypeScript
+to JavaScript and retargeted at this build's individual `@ckeditor/ckeditor5-*` packages.
diff --git a/src/plugins/mermaid/insert_mermaid_command.js b/src/plugins/mermaid/insert_mermaid_command.js
new file mode 100644
index 0000000..4c49825
--- /dev/null
+++ b/src/plugins/mermaid/insert_mermaid_command.js
@@ -0,0 +1,39 @@
+import { Command } from '@ckeditor/ckeditor5-core';
+
+/** The name under which {@link InsertMermaidCommand} is registered in the editor. */
+export const INSERT_MERMAID_COMMAND = 'insertMermaidCommand';
+
+const DEFAULT_MERMAID_MARKUP = `flowchart TB
+A --> B
+B --> C`;
+
+/**
+ * The insert mermaid command.
+ *
+ * Allows to insert mermaid.
+ */
+export default class InsertMermaidCommand extends Command {
+
+	refresh() {
+		const documentSelection = this.editor.model.document.selection;
+		const selectedElement = documentSelection.getSelectedElement();
+
+		this.isEnabled = !( selectedElement && selectedElement.name === 'mermaid' );
+	}
+
+	execute( options = {} ) {
+		const model = this.editor.model;
+		let mermaidItem;
+
+		model.change( writer => {
+			mermaidItem = writer.createElement( 'mermaid', {
+				displayMode: options.displayMode ?? 'split',
+				source: options.source ?? DEFAULT_MERMAID_MARKUP
+			} );
+
+			model.insertContent( mermaidItem );
+		} );
+
+		return mermaidItem;
+	}
+}
diff --git a/src/plugins/mermaid/mermaid.js b/src/plugins/mermaid/mermaid.js
new file mode 100644
index 0000000..e7fc39a
--- /dev/null
+++ b/src/plugins/mermaid/mermaid.js
@@ -0,0 +1,28 @@
+import { Plugin } from '@ckeditor/ckeditor5-core';
+
+import '../../theme/mermaid.css';
+import MermaidEditing from './mermaid_editing';
+import MermaidToolbar from './mermaid_toolbar';
+import MermaidUI from './mermaid_ui';
+
+/**
+ * The Mermaid diagram feature: a `` block widget with a source textarea and a rendered
+ * preview, switchable between source / split / preview modes.
+ *
+ * The mermaid library itself is not a dependency - the host supplies it through
+ * `config.mermaid.lazyLoad`, so the diagram renderer is only fetched when a diagram is shown.
+ *
+ * Derived from CKSource's `@ckeditor/ckeditor5-mermaid`; see `LICENSE.md` and `README.md` next to
+ * this file.
+ */
+export default class Mermaid extends Plugin {
+
+	static get requires() {
+		return [ MermaidEditing, MermaidToolbar, MermaidUI ];
+	}
+
+	static get pluginName() {
+		return 'Mermaid';
+	}
+
+}
diff --git a/src/plugins/mermaid/mermaid_editing.js b/src/plugins/mermaid/mermaid_editing.js
new file mode 100644
index 0000000..a5125df
--- /dev/null
+++ b/src/plugins/mermaid/mermaid_editing.js
@@ -0,0 +1,301 @@
+import { Plugin } from '@ckeditor/ckeditor5-core';
+import { toWidget } from '@ckeditor/ckeditor5-widget';
+import { uid } from '@ckeditor/ckeditor5-utils';
+
+import { textContentOf } from '../code-block/converters';
+import MermaidPreviewCommand from './mermaid_preview_command';
+import MermaidSourceViewCommand from './mermaid_source_view_command';
+import MermaidSplitViewCommand from './mermaid_split_view_command';
+import InsertMermaidCommand, { INSERT_MERMAID_COMMAND } from './insert_mermaid_command';
+import { debounce } from './utils';
+
+// Time in milliseconds.
+const DEBOUNCE_TIME = 300;
+
+const DEFAULT_DISPLAY_MODE = 'split';
+
+export default class MermaidEditing extends Plugin {
+
+	static get pluginName() {
+		return 'MermaidEditing';
+	}
+
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * Per-preview-element render generation. A single shared counter would discard in-flight
+		 * results for other widgets when several diagrams render at once, leaving blank previews.
+		 */
+		this._renderGenerations = new WeakMap();
+
+		/**
+		 * Serialize mermaid.render() calls. Concurrent renders share temporary DOM measurement
+		 * nodes inside the mermaid library and can leave blank or broken SVGs.
+		 */
+		this._renderQueue = Promise.resolve();
+	}
+
+	init() {
+		this._registerCommands();
+		this._defineConverters();
+		this._config = this.editor.config.get( 'mermaid' );
+	}
+
+	afterInit() {
+		this.editor.model.schema.register( 'mermaid', {
+			allowAttributes: [ 'displayMode', 'source' ],
+			allowWhere: '$block',
+			isObject: true
+		} );
+	}
+
+	_registerCommands() {
+		const editor = this.editor;
+
+		editor.commands.add( 'mermaidPreviewCommand', new MermaidPreviewCommand( editor ) );
+		editor.commands.add( 'mermaidSplitViewCommand', new MermaidSplitViewCommand( editor ) );
+		editor.commands.add( 'mermaidSourceViewCommand', new MermaidSourceViewCommand( editor ) );
+		editor.commands.add( INSERT_MERMAID_COMMAND, new InsertMermaidCommand( editor ) );
+	}
+
+	_defineConverters() {
+		const editor = this.editor;
+
+		editor.data.downcastDispatcher.on( 'insert:mermaid', this._mermaidDataDowncast.bind( this ) );
+		editor.editing.downcastDispatcher.on( 'insert:mermaid', this._mermaidDowncast.bind( this ) );
+		editor.editing.downcastDispatcher.on( 'attribute:source:mermaid', this._sourceAttributeDowncast.bind( this ) );
+
+		// The code block plugin claims every `
` on `high`, consuming the `` inside it
+		// without dispatching it, so a mermaid block has to be picked up before that happens.
+		editor.data.upcastDispatcher.on( 'element:pre', this._mermaidUpcast.bind( this ), { priority: 'highest' } );
+
+		editor.conversion.for( 'editingDowncast' ).attributeToAttribute( {
+			model: {
+				name: 'mermaid',
+				key: 'displayMode'
+			},
+			view: modelAttributeValue => ( {
+				key: 'class',
+				value: 'ck-mermaid__' + modelAttributeValue + '-mode'
+			} )
+		} );
+	}
+
+	_mermaidDataDowncast( evt, data, conversionApi ) {
+		const { writer, mapper } = conversionApi;
+
+		if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) {
+			return;
+		}
+
+		const targetViewPosition = mapper.toViewPosition( this.editor.model.createPositionBefore( data.item ) );
+		// For downcast we're using only language-mermaid class. We don't set class to
+		// `mermaid language-mermaid` as multiple markdown converters that we have seen are using
+		// only `language-mermaid` class and not `mermaid` alone.
+		const code = writer.createContainerElement( 'code', { class: 'language-mermaid' } );
+		const pre = writer.createContainerElement( 'pre', { spellcheck: 'false' } );
+
+		writer.insert( writer.createPositionAt( code, 'end' ), writer.createText( data.item.getAttribute( 'source' ) ) );
+		writer.insert( writer.createPositionAt( pre, 'end' ), code );
+		writer.insert( targetViewPosition, pre );
+		mapper.bindElements( data.item, code );
+	}
+
+	_mermaidDowncast( evt, data, conversionApi ) {
+		const { writer, mapper, consumable } = conversionApi;
+		const { editor } = this;
+		const { model, t } = editor;
+		const that = this;
+
+		if ( !consumable.consume( data.item, 'insert' ) ) {
+			return;
+		}
+
+		const targetViewPosition = mapper.toViewPosition( model.createPositionBefore( data.item ) );
+
+		const wrapper = writer.createContainerElement( 'div', { class: [ 'ck-mermaid__wrapper' ] } );
+		const editingContainer = writer.createUIElement( 'textarea', {
+			class: [ 'ck-mermaid__editing-view' ],
+			placeholder: t( 'Insert Mermaid source code' ),
+			'data-cke-ignore-events': true
+		}, createEditingTextarea );
+		const previewContainer = writer.createUIElement( 'div', { class: [ 'ck-mermaid__preview' ] }, createMermaidPreview );
+
+		writer.insert( writer.createPositionAt( wrapper, 'start' ), previewContainer );
+		writer.insert( writer.createPositionAt( wrapper, 'start' ), editingContainer );
+
+		writer.insert( targetViewPosition, wrapper );
+
+		mapper.bindElements( data.item, wrapper );
+
+		return toWidget( wrapper, writer, {
+			label: t( 'Mermaid widget' ),
+			hasSelectionHandle: true
+		} );
+
+		function createEditingTextarea( domDocument ) {
+			const domElement = this.toDomElement( domDocument );
+
+			domElement.value = data.item.getAttribute( 'source' );
+
+			domElement.addEventListener( 'input', debounce( event => {
+				editor.model.change( writer => {
+					writer.setAttribute( 'source', event.target.value, data.item );
+				} );
+			}, DEBOUNCE_TIME ) );
+
+			/* Workaround for internal #1544 */
+			domElement.addEventListener( 'focus', () => {
+				const model = editor.model;
+
+				// Move the selection onto the mermaid widget if it's currently not selected.
+				if ( model.document.selection.getSelectedElement() !== data.item ) {
+					model.change( writer => writer.setSelection( data.item, 'on' ) );
+				}
+			}, true );
+
+			return domElement;
+		}
+
+		function createMermaidPreview( domDocument ) {
+			const domElement = this.toDomElement( domDocument );
+
+			that.renderMermaid( domElement, data.item.getAttribute( 'source' ) );
+
+			return domElement;
+		}
+	}
+
+	_sourceAttributeDowncast( evt, data, conversionApi ) {
+		const newSource = data.attributeNewValue ?? '';
+		const domConverter = this.editor.editing.view.domConverter;
+		const mermaidView = conversionApi.mapper.toViewElement( data.item );
+
+		if ( !mermaidView ) {
+			return;
+		}
+
+		for ( const child of mermaidView.getChildren() ) {
+			if ( child.name === 'textarea' && child.hasClass( 'ck-mermaid__editing-view' ) ) {
+				const domEditingTextarea = domConverter.viewToDom( child );
+
+				if ( domEditingTextarea.value != newSource ) {
+					domEditingTextarea.value = newSource;
+				}
+			} else if ( child.name === 'div' && child.hasClass( 'ck-mermaid__preview' ) ) {
+				const domPreviewWrapper = domConverter.viewToDom( child );
+
+				if ( domPreviewWrapper ) {
+					this.renderMermaid( domPreviewWrapper, newSource );
+				}
+			}
+		}
+	}
+
+	_mermaidUpcast( evt, data, conversionApi ) {
+		const { consumable, writer } = conversionApi;
+		const viewPreElement = data.viewItem;
+		const viewCodeElement = Array.from( viewPreElement.getChildren() )
+			.find( child => child.is( 'element', 'code' ) && child.hasClass( 'language-mermaid' ) );
+
+		if ( !viewCodeElement || data.modelCursor.findAncestor( 'mermaid' ) ) {
+			return;
+		}
+
+		if ( !consumable.test( viewPreElement, { name: true } ) || !consumable.test( viewCodeElement, { name: true } ) ) {
+			return;
+		}
+
+		const mermaidElement = writer.createElement( 'mermaid', {
+			// markdown-it terminates a fenced block with a newline that is not part of the source.
+			source: textContentOf( viewCodeElement ).replace( /\n$/, '' ),
+			displayMode: DEFAULT_DISPLAY_MODE
+		} );
+
+		if ( !conversionApi.safeInsert( mermaidElement, data.modelCursor ) ) {
+			return;
+		}
+
+		consumable.consume( viewPreElement, { name: true } );
+		consumable.consume( viewCodeElement, { name: true } );
+
+		conversionApi.updateConversionResult( mermaidElement, data );
+	}
+
+	/**
+	 * Renders the given mermaid `source` into `domElement`.
+	 *
+	 * The mermaid library itself is never imported here: the host supplies it through the
+	 * `mermaid.lazyLoad` config, which is called once and memoised.
+	 */
+	async renderMermaid( domElement, source ) {
+		if ( !source?.trim() ) {
+			// Bump the generation so an in-flight render for the previous source cannot write its
+			// SVG back into a cleared preview.
+			this._nextGeneration( domElement );
+			domElement.innerHTML = '';
+			return;
+		}
+
+		if ( !this._mermaidPromise && typeof this._config?.lazyLoad === 'function' ) {
+			this._mermaidPromise = Promise.resolve( this._config.lazyLoad() ).then( instance => {
+				instance.initialize( this._config?.config ?? {} );
+				return instance;
+			} );
+		}
+
+		const mermaid = await this._mermaidPromise;
+
+		if ( !mermaid ) {
+			return;
+		}
+
+		const generation = this._nextGeneration( domElement );
+		const id = `ck-mermaid-${ uid() }`;
+
+		const run = async () => {
+			// A newer edit for this same preview landed while we waited in the queue.
+			if ( generation !== this._renderGenerations.get( domElement ) ) {
+				return;
+			}
+
+			try {
+				const { svg } = await mermaid.render( id, source );
+
+				// Mermaid leaves a temporary probe node with `id`. Remove it *before* inserting the
+				// SVG - the returned SVG reuses the same id, so a later getElementById( id ).remove()
+				// would delete the rendered diagram.
+				document.getElementById( id )?.remove();
+
+				if ( generation === this._renderGenerations.get( domElement ) ) {
+					domElement.innerHTML = svg;
+				}
+			} catch ( err ) {
+				document.getElementById( id )?.remove();
+
+				if ( generation === this._renderGenerations.get( domElement ) ) {
+					domElement.innerText = err instanceof Error ? err.message : String( err );
+				}
+			}
+		};
+
+		// Chain onto the queue so only one mermaid.render runs at a time, while still letting each
+		// caller's promise settle when *its* turn finishes. `run` swallows its own failures, so the
+		// chain never rejects; passing it as the rejection handler too means a future throw would
+		// still let the next render through instead of wedging the queue permanently.
+		const queued = this._renderQueue.then( run, run );
+
+		this._renderQueue = queued;
+
+		await queued;
+	}
+
+	_nextGeneration( domElement ) {
+		const generation = ( this._renderGenerations.get( domElement ) ?? 0 ) + 1;
+
+		this._renderGenerations.set( domElement, generation );
+
+		return generation;
+	}
+}
diff --git a/src/plugins/mermaid/mermaid_preview_command.js b/src/plugins/mermaid/mermaid_preview_command.js
new file mode 100644
index 0000000..c3a8c3d
--- /dev/null
+++ b/src/plugins/mermaid/mermaid_preview_command.js
@@ -0,0 +1,39 @@
+import { Command } from '@ckeditor/ckeditor5-core';
+import { checkIsOn } from './utils';
+
+/**
+ * Switches the selected mermaid widget to the preview mode.
+ */
+export default class MermaidPreviewCommand extends Command {
+
+	refresh() {
+		const editor = this.editor;
+		const documentSelection = editor.model.document.selection;
+		const selectedElement = documentSelection.getSelectedElement();
+		const isSelectedElementMermaid = selectedElement && selectedElement.name === 'mermaid';
+
+		if ( isSelectedElementMermaid || documentSelection.getLastPosition()?.findAncestor( 'mermaid' ) ) {
+			this.isEnabled = !!selectedElement;
+		} else {
+			this.isEnabled = false;
+		}
+
+		this.value = checkIsOn( editor, 'preview' );
+	}
+
+	execute() {
+		const model = this.editor.model;
+		// `mermaid` is an object element, so the selection is always *on* it - never inside.
+		const mermaidItem = this.editor.model.document.selection.getSelectedElement();
+
+		if ( !mermaidItem ) {
+			return;
+		}
+
+		model.change( writer => {
+			if ( mermaidItem.getAttribute( 'displayMode' ) !== 'preview' ) {
+				writer.setAttribute( 'displayMode', 'preview', mermaidItem );
+			}
+		} );
+	}
+}
diff --git a/src/plugins/mermaid/mermaid_source_view_command.js b/src/plugins/mermaid/mermaid_source_view_command.js
new file mode 100644
index 0000000..bc48df7
--- /dev/null
+++ b/src/plugins/mermaid/mermaid_source_view_command.js
@@ -0,0 +1,39 @@
+import { Command } from '@ckeditor/ckeditor5-core';
+import { checkIsOn } from './utils';
+
+/**
+ * Switches the selected mermaid widget to the source view mode.
+ */
+export default class MermaidSourceViewCommand extends Command {
+
+	refresh() {
+		const editor = this.editor;
+		const documentSelection = editor.model.document.selection;
+		const selectedElement = documentSelection.getSelectedElement();
+		const isSelectedElementMermaid = selectedElement && selectedElement.name === 'mermaid';
+
+		if ( isSelectedElementMermaid || documentSelection.getLastPosition()?.findAncestor( 'mermaid' ) ) {
+			this.isEnabled = !!selectedElement;
+		} else {
+			this.isEnabled = false;
+		}
+
+		this.value = checkIsOn( editor, 'source' );
+	}
+
+	execute() {
+		const model = this.editor.model;
+		// `mermaid` is an object element, so the selection is always *on* it - never inside.
+		const mermaidItem = this.editor.model.document.selection.getSelectedElement();
+
+		if ( !mermaidItem ) {
+			return;
+		}
+
+		model.change( writer => {
+			if ( mermaidItem.getAttribute( 'displayMode' ) !== 'source' ) {
+				writer.setAttribute( 'displayMode', 'source', mermaidItem );
+			}
+		} );
+	}
+}
diff --git a/src/plugins/mermaid/mermaid_split_view_command.js b/src/plugins/mermaid/mermaid_split_view_command.js
new file mode 100644
index 0000000..a6fba12
--- /dev/null
+++ b/src/plugins/mermaid/mermaid_split_view_command.js
@@ -0,0 +1,39 @@
+import { Command } from '@ckeditor/ckeditor5-core';
+import { checkIsOn } from './utils';
+
+/**
+ * Switches the selected mermaid widget to the split view mode.
+ */
+export default class MermaidSplitViewCommand extends Command {
+
+	refresh() {
+		const editor = this.editor;
+		const documentSelection = editor.model.document.selection;
+		const selectedElement = documentSelection.getSelectedElement();
+		const isSelectedElementMermaid = selectedElement && selectedElement.name === 'mermaid';
+
+		if ( isSelectedElementMermaid || documentSelection.getLastPosition()?.findAncestor( 'mermaid' ) ) {
+			this.isEnabled = !!selectedElement;
+		} else {
+			this.isEnabled = false;
+		}
+
+		this.value = checkIsOn( editor, 'split' );
+	}
+
+	execute() {
+		const model = this.editor.model;
+		// `mermaid` is an object element, so the selection is always *on* it - never inside.
+		const mermaidItem = this.editor.model.document.selection.getSelectedElement();
+
+		if ( !mermaidItem ) {
+			return;
+		}
+
+		model.change( writer => {
+			if ( mermaidItem.getAttribute( 'displayMode' ) !== 'split' ) {
+				writer.setAttribute( 'displayMode', 'split', mermaidItem );
+			}
+		} );
+	}
+}
diff --git a/src/plugins/mermaid/mermaid_toolbar.js b/src/plugins/mermaid/mermaid_toolbar.js
new file mode 100644
index 0000000..6d03e17
--- /dev/null
+++ b/src/plugins/mermaid/mermaid_toolbar.js
@@ -0,0 +1,34 @@
+import { Plugin } from '@ckeditor/ckeditor5-core';
+import { WidgetToolbarRepository } from '@ckeditor/ckeditor5-widget';
+
+export default class MermaidToolbar extends Plugin {
+
+	static get requires() {
+		return [ WidgetToolbarRepository ];
+	}
+
+	static get pluginName() {
+		return 'MermaidToolbar';
+	}
+
+	afterInit() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		editor.plugins.get( WidgetToolbarRepository ).register( 'mermaidToolbar', {
+			ariaLabel: t( 'Mermaid toolbar' ),
+			items: [ 'mermaidSourceView', 'mermaidSplitView', 'mermaidPreview', '|', 'mermaidInfo' ],
+			getRelatedElement: selection => getSelectedElement( selection )
+		} );
+	}
+}
+
+function getSelectedElement( selection ) {
+	const viewElement = selection.getSelectedElement();
+
+	if ( viewElement && viewElement.hasClass( 'ck-mermaid__wrapper' ) ) {
+		return viewElement;
+	}
+
+	return null;
+}
diff --git a/src/plugins/mermaid/mermaid_ui.js b/src/plugins/mermaid/mermaid_ui.js
new file mode 100644
index 0000000..6fa577c
--- /dev/null
+++ b/src/plugins/mermaid/mermaid_ui.js
@@ -0,0 +1,177 @@
+// These SVG file imports are handled by webpack's raw-text loader,
+// so each icon holds the source SVG.
+import insertMermaidIcon from '../../icons/mermaid-insert.svg';
+import previewModeIcon from '../../icons/mermaid-preview-mode.svg';
+import splitModeIcon from '../../icons/mermaid-split-mode.svg';
+import sourceModeIcon from '../../icons/mermaid-source-mode.svg';
+import infoIcon from '../../icons/mermaid-info.svg';
+
+import { Plugin } from '@ckeditor/ckeditor5-core';
+import { addListToDropdown, ButtonView, createDropdown, SplitButtonView, ViewModel } from '@ckeditor/ckeditor5-ui';
+import { Collection } from '@ckeditor/ckeditor5-utils';
+
+import { INSERT_MERMAID_COMMAND } from './insert_mermaid_command';
+
+const MERMAID_DOCS_URL = 'https://mermaid.js.org/intro/';
+
+export default class MermaidUI extends Plugin {
+
+	static get pluginName() {
+		return 'MermaidUI';
+	}
+
+	init() {
+		this._addButtons();
+	}
+
+	_addButtons() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		this._addInsertMermaidButton();
+		this._addMermaidInfoButton();
+		this._createToolbarButton( 'mermaidPreview', t( 'Preview' ), previewModeIcon );
+		this._createToolbarButton( 'mermaidSourceView', t( 'Source view' ), sourceModeIcon );
+		this._createToolbarButton( 'mermaidSplitView', t( 'Split view' ), splitModeIcon );
+	}
+
+	/**
+	 * Adds the button inserting a diagram. With `mermaid.samples` configured it becomes a split
+	 * button whose dropdown lists those templates, the main action still inserting a blank diagram.
+	 */
+	_addInsertMermaidButton() {
+		const editor = this.editor;
+		const t = editor.t;
+		const samples = editor.config.get( 'mermaid.samples' ) ?? [];
+
+		editor.ui.componentFactory.add( 'mermaid', locale => {
+			const command = editor.commands.get( INSERT_MERMAID_COMMAND );
+			const buttonProperties = {
+				label: t( 'Mermaid diagram' ),
+				icon: insertMermaidIcon,
+				tooltip: true
+			};
+
+			if ( !samples.length ) {
+				const buttonView = new ButtonView( locale );
+
+				buttonView.set( buttonProperties );
+				buttonView.bind( 'isEnabled' ).to( command, 'isEnabled' );
+				buttonView.on( 'execute', () => this._insertDiagram() );
+
+				return buttonView;
+			}
+
+			const dropdownView = createDropdown( locale, SplitButtonView );
+
+			dropdownView.buttonView.set( buttonProperties );
+			dropdownView.buttonView.on( 'execute', () => this._insertDiagram() );
+
+			addListToDropdown( dropdownView, this._getSampleDropdownItems( samples ) );
+
+			// `createDropdown` already binds the split button's `isEnabled` to the
+			// dropdown, so binding the dropdown alone disables both parts.
+			dropdownView.bind( 'isEnabled' ).to( command, 'isEnabled' );
+			dropdownView.on( 'execute', evt => {
+				this._insertDiagram( { source: evt.source?.commandParam } );
+			} );
+
+			return dropdownView;
+		} );
+	}
+
+	/**
+	 * Inserts a diagram - blank by default, or pre-filled with the given template source - and
+	 * moves the focus into its editing view.
+	 */
+	_insertDiagram( options = {} ) {
+		const editor = this.editor;
+		const view = editor.editing.view;
+
+		const mermaidItem = editor.execute( INSERT_MERMAID_COMMAND, options );
+		const mermaidItemViewElement = editor.editing.mapper.toViewElement( mermaidItem );
+
+		view.scrollToTheSelection();
+		view.focus();
+
+		if ( mermaidItemViewElement ) {
+			const mermaidItemDomElement = view.domConverter.viewToDom( mermaidItemViewElement );
+
+			mermaidItemDomElement?.querySelector( '.ck-mermaid__editing-view' )?.focus();
+		}
+	}
+
+	_getSampleDropdownItems( samples ) {
+		const itemDefinitions = new Collection();
+
+		for ( const sample of samples ) {
+			itemDefinitions.add( {
+				type: 'button',
+				model: new ViewModel( {
+					commandParam: sample.content,
+					label: sample.name,
+					role: 'menuitem',
+					withText: true
+				} )
+			} );
+		}
+
+		return itemDefinitions;
+	}
+
+	_addMermaidInfoButton() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		editor.ui.componentFactory.add( 'mermaidInfo', locale => {
+			const buttonView = new ButtonView( locale );
+
+			buttonView.set( {
+				label: t( 'Read more about Mermaid diagram syntax' ),
+				icon: infoIcon,
+				tooltip: true
+			} );
+
+			buttonView.on( 'execute', () => {
+				const openHelp = editor.config.get( 'mermaid.openHelp' );
+
+				if ( openHelp ) {
+					openHelp();
+				} else {
+					window.open( MERMAID_DOCS_URL, '_blank', 'noopener' );
+				}
+			} );
+
+			return buttonView;
+		} );
+	}
+
+	/**
+	 * Adds one of the display mode buttons shown in the widget's balloon toolbar.
+	 * `label` arrives already translated - see `_addButtons()`.
+	 */
+	_createToolbarButton( name, label, icon ) {
+		const editor = this.editor;
+
+		editor.ui.componentFactory.add( name, locale => {
+			const buttonView = new ButtonView( locale );
+			const command = editor.commands.get( `${ name }Command` );
+
+			buttonView.set( {
+				label,
+				icon,
+				tooltip: true
+			} );
+
+			buttonView.bind( 'isOn', 'isEnabled' ).to( command, 'value', 'isEnabled' );
+
+			command.listenTo( buttonView, 'execute', () => {
+				editor.execute( `${ name }Command` );
+				editor.editing.view.scrollToTheSelection();
+				editor.editing.view.focus();
+			} );
+
+			return buttonView;
+		} );
+	}
+}
diff --git a/src/plugins/mermaid/utils.js b/src/plugins/mermaid/utils.js
new file mode 100644
index 0000000..d935038
--- /dev/null
+++ b/src/plugins/mermaid/utils.js
@@ -0,0 +1,30 @@
+/**
+ * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+export function debounce( fn, waitMs ) {
+	let timeout = null;
+
+	return function( ...args ) {
+		if ( timeout ) {
+			clearTimeout( timeout );
+		}
+
+		timeout = setTimeout( () => fn.apply( this, args ), waitMs );
+	};
+}
+
+/**
+ * Helper function for setting the `isOn` state of buttons.
+ *
+ * @param commandName Short name of the command.
+ */
+export function checkIsOn( editor, commandName ) {
+	const selection = editor.model.document.selection;
+	const mermaidItem = selection.getSelectedElement() || selection.getLastPosition()?.parent;
+
+	return !!mermaidItem &&
+		mermaidItem.is( 'element', 'mermaid' ) &&
+		mermaidItem.getAttribute( 'displayMode' ) === commandName;
+}
diff --git a/src/theme/mermaid.css b/src/theme/mermaid.css
new file mode 100644
index 0000000..c1bfa02
--- /dev/null
+++ b/src/theme/mermaid.css
@@ -0,0 +1,71 @@
+/* Mermaid wrapper */
+.ck-mermaid__wrapper {
+	display: flex;
+	justify-content: center;
+}
+
+/* Editing
+ Assigned to textarea
+ */
+.ck-mermaid__wrapper .ck-mermaid__editing-view {
+	padding: 16px; /* just like pre element */
+	background-color: hsla(0, 0%, 78%, 0.3); /* just like pre element */
+	min-height: 200px;
+	border: 0;
+	resize: vertical;
+	font-size: 12px; /* just like pre element */
+	outline: 0; /* just like pre element */
+}
+
+/* Preview */
+.ck-mermaid__preview {
+	display: flex;
+	justify-content: center;
+	align-items: flex-start;
+	overflow-x: auto;
+	min-width: 0;
+}
+
+/* Keep the SVG's intrinsic size so wide flowcharts stay readable; the
+   preview pane scrolls horizontally instead of squashing into a thin strip. */
+.ck-mermaid__preview svg {
+	flex-shrink: 0;
+	max-width: none;
+}
+
+/* Preview error state */
+[id^="dmermaid-"] {
+	display: flex;
+	align-items: center;
+	height: 100%;
+	justify-content: center;
+}
+
+[id^="dmermaid-"] svg {
+	display: block;
+	width: 60%;
+	height: auto;
+}
+
+/* Source mode */
+.ck-mermaid__source-mode .ck-mermaid__editing-view {
+	width: 100%;
+}
+
+.ck-mermaid__source-mode .ck-mermaid__preview {
+	display: none;
+}
+
+/* Split mode */
+.ck-mermaid__split-mode .ck-mermaid__editing-view {
+	width: 50%;
+}
+
+.ck-mermaid__split-mode .ck-mermaid__preview {
+	width: 50%;
+}
+
+/* Preview mode */
+.ck-mermaid__preview-mode .ck-mermaid__editing-view {
+	display: none;
+}
\ No newline at end of file
diff --git a/tests/plugins/mermaid/insert-mermaid-command.test.js b/tests/plugins/mermaid/insert-mermaid-command.test.js
new file mode 100644
index 0000000..483e1fb
--- /dev/null
+++ b/tests/plugins/mermaid/insert-mermaid-command.test.js
@@ -0,0 +1,70 @@
+import { ObservableMixin } from '@ckeditor/ckeditor5-utils';
+import InsertMermaidCommand from '../../../src/plugins/mermaid/insert_mermaid_command';
+
+class FakeEditor extends ObservableMixin() {}
+
+// Minimal stand-in for the model: `change()` hands out a writer whose
+// `createElement` records what the command asked for.
+function fakeEditor( selectedElement = null ) {
+	const editor = new FakeEditor();
+	const writer = {
+		createElement: jest.fn( ( name, attributes ) => ( { name, attributes } ) ),
+	};
+
+	editor.model = {
+		change: callback => callback( writer ),
+		insertContent: jest.fn(),
+		canEditAt: () => true,
+		document: {
+			selection: {
+				getSelectedElement: () => selectedElement,
+				// Command#isEnabled checks whether the selection sits in the graveyard.
+				getFirstPosition: () => ( { root: { rootName: 'main' } } ),
+			}
+		}
+	};
+	editor.model.writer = writer;
+
+	return editor;
+}
+
+describe( 'InsertMermaidCommand', () => {
+	it( 'is disabled while a diagram is selected', () => {
+		const command = new InsertMermaidCommand( fakeEditor( { name: 'mermaid' } ) );
+
+		command.refresh();
+
+		expect( command.isEnabled ).toBe( false );
+	} );
+
+	it( 'is enabled elsewhere', () => {
+		const command = new InsertMermaidCommand( fakeEditor( { name: 'paragraph' } ) );
+
+		command.refresh();
+
+		expect( command.isEnabled ).toBe( true );
+	} );
+
+	it( 'inserts a diagram with a starter source in split mode', () => {
+		const editor = fakeEditor();
+		const command = new InsertMermaidCommand( editor );
+
+		command.refresh();
+		const mermaidItem = command.execute();
+
+		expect( mermaidItem.name ).toEqual( 'mermaid' );
+		expect( mermaidItem.attributes.displayMode ).toEqual( 'split' );
+		expect( mermaidItem.attributes.source ).toContain( 'flowchart TB' );
+		expect( editor.model.insertContent ).toHaveBeenCalledWith( mermaidItem );
+	} );
+
+	it( 'inserts the source of a picked template', () => {
+		const editor = fakeEditor();
+		const command = new InsertMermaidCommand( editor );
+
+		command.refresh();
+		const mermaidItem = command.execute( { source: 'pie title Votes' } );
+
+		expect( mermaidItem.attributes.source ).toEqual( 'pie title Votes' );
+	} );
+} );
diff --git a/tests/plugins/mermaid/mermaid-editing.test.js b/tests/plugins/mermaid/mermaid-editing.test.js
new file mode 100644
index 0000000..b9b1a9d
--- /dev/null
+++ b/tests/plugins/mermaid/mermaid-editing.test.js
@@ -0,0 +1,184 @@
+import MermaidEditing from '../../../src/plugins/mermaid/mermaid_editing';
+
+// Minimal stand-ins for CKEditor view nodes, enough for the upcast and for
+// `textContentOf` to walk the  element.
+function viewText( data ) {
+	return {
+		data,
+		is: type => type === '$text',
+	};
+}
+
+function viewElement( name, { classes = [], children = [] } = {} ) {
+	return {
+		name,
+		is: ( type, elementName ) => type === 'element' && ( elementName === undefined || elementName === name ),
+		hasClass: cls => classes.includes( cls ),
+		getChildren: () => children,
+	};
+}
+
+function codeBlock( language, source ) {
+	return viewElement( 'pre', {
+		children: [ viewElement( 'code', { classes: [ language ], children: [ viewText( source ) ] } ) ],
+	} );
+}
+
+function upcastArgs( viewPre ) {
+	const conversionApi = {
+		consumable: {
+			test: jest.fn( () => true ),
+			consume: jest.fn( () => true ),
+		},
+		writer: {
+			createElement: jest.fn( ( name, attributes ) => ( { name, attributes } ) ),
+		},
+		safeInsert: jest.fn( () => true ),
+		updateConversionResult: jest.fn(),
+	};
+
+	const data = {
+		viewItem: viewPre,
+		modelCursor: { findAncestor: () => null },
+	};
+
+	return { data, conversionApi };
+}
+
+describe( 'MermaidEditing', () => {
+	describe( 'upcast', () => {
+		it( 'converts a mermaid code block into a mermaid element', () => {
+			const plugin = new MermaidEditing( {} );
+			const { data, conversionApi } = upcastArgs( codeBlock( 'language-mermaid', 'flowchart TB\nA --> B' ) );
+
+			plugin._mermaidUpcast( null, data, conversionApi );
+
+			expect( conversionApi.writer.createElement ).toHaveBeenCalledWith( 'mermaid', {
+				source: 'flowchart TB\nA --> B',
+				displayMode: 'split',
+			} );
+			expect( conversionApi.safeInsert ).toHaveBeenCalled();
+			expect( conversionApi.updateConversionResult ).toHaveBeenCalled();
+			// Both the 
 and the  are claimed, so the code block plugin leaves them alone.
+			expect( conversionApi.consumable.consume ).toHaveBeenCalledTimes( 2 );
+		} );
+
+		it( 'drops the trailing newline markdown-it adds to a fenced block', () => {
+			const plugin = new MermaidEditing( {} );
+			const { data, conversionApi } = upcastArgs( codeBlock( 'language-mermaid', 'flowchart TB\n' ) );
+
+			plugin._mermaidUpcast( null, data, conversionApi );
+
+			expect( conversionApi.writer.createElement ).toHaveBeenCalledWith( 'mermaid', {
+				source: 'flowchart TB',
+				displayMode: 'split',
+			} );
+		} );
+
+		it( 'leaves a code block of another language to the code block plugin', () => {
+			const plugin = new MermaidEditing( {} );
+			const { data, conversionApi } = upcastArgs( codeBlock( 'language-ruby', 'puts 1' ) );
+
+			plugin._mermaidUpcast( null, data, conversionApi );
+
+			expect( conversionApi.writer.createElement ).not.toHaveBeenCalled();
+			expect( conversionApi.consumable.consume ).not.toHaveBeenCalled();
+		} );
+
+		it( 'consumes nothing when the element cannot be inserted', () => {
+			const plugin = new MermaidEditing( {} );
+			const { data, conversionApi } = upcastArgs( codeBlock( 'language-mermaid', 'flowchart TB' ) );
+
+			conversionApi.safeInsert.mockReturnValue( false );
+
+			plugin._mermaidUpcast( null, data, conversionApi );
+
+			expect( conversionApi.consumable.consume ).not.toHaveBeenCalled();
+			expect( conversionApi.updateConversionResult ).not.toHaveBeenCalled();
+		} );
+	} );
+
+	describe( 'renderMermaid', () => {
+		function pluginWith( mermaid, config = {} ) {
+			const plugin = new MermaidEditing( {} );
+
+			plugin._config = {
+				lazyLoad: jest.fn( () => mermaid ),
+				config,
+			};
+
+			return plugin;
+		}
+
+		function mermaidStub() {
+			return {
+				initialize: jest.fn(),
+				render: jest.fn( ( id, source ) => Promise.resolve( { svg: `${ source }` } ) ),
+			};
+		}
+
+		it( 'initializes the lazily loaded library once and renders into the element', async () => {
+			const mermaid = mermaidStub();
+			const plugin = pluginWith( mermaid, { theme: 'neutral' } );
+			const domElement = document.createElement( 'div' );
+
+			await plugin.renderMermaid( domElement, 'graph TD' );
+			await plugin.renderMermaid( domElement, 'graph LR' );
+
+			expect( plugin._config.lazyLoad ).toHaveBeenCalledTimes( 1 );
+			expect( mermaid.initialize ).toHaveBeenCalledWith( { theme: 'neutral' } );
+			expect( domElement.innerHTML ).toEqual( 'graph LR' );
+		} );
+
+		it( 'clears the preview for an empty source without rendering', async () => {
+			const mermaid = mermaidStub();
+			const plugin = pluginWith( mermaid );
+			const domElement = document.createElement( 'div' );
+
+			domElement.innerHTML = 'stale';
+
+			await plugin.renderMermaid( domElement, '   ' );
+
+			expect( domElement.innerHTML ).toEqual( '' );
+			expect( mermaid.render ).not.toHaveBeenCalled();
+		} );
+
+		it( 'shows the error of a diagram that fails to render', async () => {
+			const mermaid = mermaidStub();
+			const plugin = pluginWith( mermaid );
+			const domElement = document.createElement( 'div' );
+
+			mermaid.render.mockRejectedValue( new Error( 'Parse error on line 1' ) );
+
+			await plugin.renderMermaid( domElement, 'not a diagram' );
+
+			expect( domElement.innerText ).toEqual( 'Parse error on line 1' );
+		} );
+
+		it( 'keeps the result of the newest render when an older one finishes last', async () => {
+			const mermaid = mermaidStub();
+			const plugin = pluginWith( mermaid );
+			const domElement = document.createElement( 'div' );
+
+			const stale = plugin.renderMermaid( domElement, 'graph TD' );
+			const current = plugin.renderMermaid( domElement, 'graph LR' );
+
+			await Promise.all( [ stale, current ] );
+
+			expect( domElement.innerHTML ).toEqual( 'graph LR' );
+			// The superseded render never reaches the library.
+			expect( mermaid.render ).toHaveBeenCalledTimes( 1 );
+		} );
+
+		it( 'does nothing when the host configured no library', async () => {
+			const plugin = new MermaidEditing( {} );
+			const domElement = document.createElement( 'div' );
+
+			domElement.innerHTML = 'kept';
+
+			await plugin.renderMermaid( domElement, 'graph TD' );
+
+			expect( domElement.innerHTML ).toEqual( 'kept' );
+		} );
+	} );
+} );
diff --git a/tests/plugins/mermaid/utils.test.js b/tests/plugins/mermaid/utils.test.js
new file mode 100644
index 0000000..2389810
--- /dev/null
+++ b/tests/plugins/mermaid/utils.test.js
@@ -0,0 +1,79 @@
+import { checkIsOn, debounce } from '../../../src/plugins/mermaid/utils';
+
+// Minimal stand-in for a model selection holding a single mermaid element.
+function editorWithSelectedElement( element ) {
+	return {
+		model: {
+			document: {
+				selection: {
+					getSelectedElement: () => element,
+					getLastPosition: () => null,
+				}
+			}
+		}
+	};
+}
+
+function mermaidElement( displayMode ) {
+	return {
+		is: ( type, name ) => type === 'element' && name === 'mermaid',
+		getAttribute: name => ( name === 'displayMode' ? displayMode : undefined ),
+	};
+}
+
+describe( 'mermaid utils', () => {
+	describe( 'debounce', () => {
+		beforeEach( () => jest.useFakeTimers() );
+		afterEach( () => jest.useRealTimers() );
+
+		it( 'calls the function once after the wait has passed', () => {
+			const fn = jest.fn();
+			const debounced = debounce( fn, 300 );
+
+			debounced( 'a' );
+			debounced( 'b' );
+			debounced( 'c' );
+
+			expect( fn ).not.toHaveBeenCalled();
+
+			jest.advanceTimersByTime( 300 );
+
+			expect( fn ).toHaveBeenCalledTimes( 1 );
+			expect( fn ).toHaveBeenCalledWith( 'c' );
+		} );
+
+		it( 'calls the function again for a later burst', () => {
+			const fn = jest.fn();
+			const debounced = debounce( fn, 300 );
+
+			debounced( 'first' );
+			jest.advanceTimersByTime( 300 );
+			debounced( 'second' );
+			jest.advanceTimersByTime( 300 );
+
+			expect( fn ).toHaveBeenCalledTimes( 2 );
+		} );
+	} );
+
+	describe( 'checkIsOn', () => {
+		it( 'is on for the mode the selected diagram is in', () => {
+			const editor = editorWithSelectedElement( mermaidElement( 'split' ) );
+
+			expect( checkIsOn( editor, 'split' ) ).toBe( true );
+			expect( checkIsOn( editor, 'preview' ) ).toBe( false );
+		} );
+
+		it( 'is off without a selected element', () => {
+			expect( checkIsOn( editorWithSelectedElement( null ), 'split' ) ).toBe( false );
+		} );
+
+		it( 'is off for a selected element that is not a diagram', () => {
+			const paragraph = {
+				is: () => false,
+				getAttribute: () => 'split',
+			};
+
+			expect( checkIsOn( editorWithSelectedElement( paragraph ), 'split' ) ).toBe( false );
+		} );
+	} );
+} );
diff --git a/webpack.config.js b/webpack.config.js
index 4d93781..1268349 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -20,6 +20,16 @@ if (!core) {
 	throw new Error("Expected OPENPROJECT_CORE to be present, but wasn't.");
 }
 
+const styleLoader = {
+	loader: 'style-loader',
+	options: {
+		injectType: 'singletonStyleTag',
+		attributes: {
+			'data-cke': true
+		}
+	}
+};
+
 module.exports = {
 	devtool: 'source-map',
 	performance: { hints: false },
@@ -65,17 +75,17 @@ module.exports = {
 				use: [ 'raw-loader' ]
 			},
 			{
+				// Our own stylesheets are plain CSS. The CKEditor PostCSS config below resolves a theme
+				// relative to the owning ckeditor5-* package, which a file of ours does not have.
 				test: /\.css$/,
+				include: path.resolve( __dirname, 'src' ),
+				use: [ styleLoader, 'css-loader' ]
+			},
+			{
+				test: /\.css$/,
+				exclude: path.resolve( __dirname, 'src' ),
 				use: [
-					{
-						loader: 'style-loader',
-						options: {
-							injectType: 'singletonStyleTag',
-							attributes: {
-								'data-cke': true
-							}
-						}
-					},
+					styleLoader,
 					'css-loader',
 					{
 						loader: 'postcss-loader',