import { Command, Plugin } from '@ckeditor/ckeditor5-core/dist/index.js'; import { toWidget, WidgetToolbarRepository } from '@ckeditor/ckeditor5-widget/dist/index.js'; import mermaid from 'mermaid/dist/mermaid.js'; import { debounce } from 'lodash-es'; import { ButtonView } from '@ckeditor/ckeditor5-ui/dist/index.js'; var infoIcon = "\n"; var insertMermaidIcon = "\n"; var previewModeIcon = "\n"; var splitModeIcon = "\n"; var sourceModeIcon = "\n"; /** * @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 */ /** * @module mermaid/utils */ /** * Helper function for setting the `isOn` state of buttons. * * @private * @param {module:core/editor/editor~Editor} editor * @param {String} commandName Short name of the command. * @returns {Boolean} */ function checkIsOn(editor, commandName) { const selection = editor.model.document.selection; const mermaidItem = selection.getSelectedElement() || selection.getLastPosition().parent; if (mermaidItem && mermaidItem.is('element', 'mermaid') && mermaidItem.getAttribute('displayMode') === commandName) { return true; } return false; } /** * The mermaid preview command. * * Allows to switch to a preview mode. * * @extends module:core/command~Command */ class MermaidPreviewCommand extends Command { /** * @inheritDoc */ 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'); } /** * @inheritDoc */ execute() { const editor = this.editor; const model = editor.model; const documentSelection = this.editor.model.document.selection; const mermaidItem = documentSelection.getSelectedElement() || documentSelection.getLastPosition().parent; model.change((writer)=>{ if (mermaidItem.getAttribute('displayMode') !== 'preview') { writer.setAttribute('displayMode', 'preview', mermaidItem); } }); } } /** * The mermaid source view command. * * Allows to switch to a source view mode. * * @extends module:core/command~Command */ class MermaidSourceViewCommand extends Command { /** * @inheritDoc */ 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'); } /** * @inheritDoc */ execute() { const editor = this.editor; const model = editor.model; const documentSelection = this.editor.model.document.selection; const mermaidItem = documentSelection.getSelectedElement() || documentSelection.getLastPosition().parent; model.change((writer)=>{ if (mermaidItem.getAttribute('displayMode') !== 'source') { writer.setAttribute('displayMode', 'source', mermaidItem); } }); } } /** * The mermaid split view command. * * Allows to switch to a split view mode. * * @extends module:core/command~Command */ class MermaidSplitViewCommand extends Command { /** * @inheritDoc */ 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'); } /** * @inheritDoc */ execute() { const editor = this.editor; const model = editor.model; const documentSelection = this.editor.model.document.selection; const mermaidItem = documentSelection.getSelectedElement() || documentSelection.getLastPosition().parent; model.change((writer)=>{ if (mermaidItem.getAttribute('displayMode') !== 'split') { writer.setAttribute('displayMode', 'split', mermaidItem); } }); } } const MOCK_MERMAID_MARKUP = `flowchart TB A --> B B --> C`; /** * The insert mermaid command. * * Allows to insert mermaid. * * @extends module:core/command~Command */ class InsertMermaidCommand extends Command { /** * @inheritDoc */ refresh() { const documentSelection = this.editor.model.document.selection; const selectedElement = documentSelection.getSelectedElement(); if (selectedElement && selectedElement.name === 'mermaid') { this.isEnabled = false; } else { this.isEnabled = true; } } /** * @inheritDoc */ execute() { const editor = this.editor; const model = editor.model; let mermaidItem; model.change((writer)=>{ mermaidItem = writer.createElement('mermaid', { displayMode: 'split', source: MOCK_MERMAID_MARKUP }); model.insertContent(mermaidItem); }); return mermaidItem; } } // Time in milliseconds. const DEBOUNCE_TIME = 300; /* global window */ class MermaidEditing extends Plugin { /** * @inheritDoc */ static get pluginName() { return 'MermaidEditing'; } /** * @inheritDoc */ init() { this._registerCommands(); this._defineConverters(); } /** * @inheritDoc */ afterInit() { this.editor.model.schema.register('mermaid', { allowAttributes: [ 'displayMode', 'source' ], allowWhere: '$block', isObject: true }); } /** * @inheritDoc */ _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('insertMermaidCommand', new InsertMermaidCommand(editor)); } /** * Adds converters. * * @private */ _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)); editor.data.upcastDispatcher.on('element:code', this._mermaidUpcast.bind(this), { priority: 'high' }); editor.conversion.for('editingDowncast').attributeToAttribute({ model: { name: 'mermaid', key: 'displayMode' }, view: (modelAttributeValue)=>({ key: 'class', value: 'ck-mermaid__' + modelAttributeValue + '-mode' }) }); } /** * * @private * @param {*} evt * @param {*} data * @param {*} conversionApi */ _mermaidDataDowncast(evt, data, conversionApi) { const model = this.editor.model; const { writer, mapper } = conversionApi; if (!conversionApi.consumable.consume(data.item, 'insert')) { return; } const targetViewPosition = mapper.toViewPosition(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' }); const sourceTextNode = writer.createText(data.item.getAttribute('source')); writer.insert(model.createPositionAt(code, 'end'), sourceTextNode); writer.insert(model.createPositionAt(pre, 'end'), code); writer.insert(targetViewPosition, pre); mapper.bindElements(data.item, code); } /** * * @private * @param {*} evt * @param {*} data * @param {*} conversionApi */ _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 wrapperAttributes = { class: [ 'ck-mermaid__wrapper' ] }; const textareaAttributes = { class: [ 'ck-mermaid__editing-view' ], placeholder: t('Insert mermaid source code'), 'data-cke-ignore-events': true }; const wrapper = writer.createContainerElement('div', wrapperAttributes); const editingContainer = writer.createUIElement('textarea', textareaAttributes, 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, { widgetLabel: t('Mermaid widget'), hasSelectionHandle: true }); function createEditingTextarea(domDocument) { const domElement = this.toDomElement(domDocument); domElement.value = data.item.getAttribute('source'); const debouncedListener = debounce((event)=>{ editor.model.change((writer)=>{ writer.setAttribute('source', event.target.value, data.item); }); }, DEBOUNCE_TIME); domElement.addEventListener('input', debouncedListener); /* Workaround for internal #1544 */ domElement.addEventListener('focus', ()=>{ const model = editor.model; const selectedElement = model.document.selection.getSelectedElement(); // Move the selection onto the mermaid widget if it's currently not selected. if (selectedElement !== data.item) { model.change((writer)=>writer.setSelection(data.item, 'on')); } }, true); return domElement; } function createMermaidPreview(domDocument) { // Taking the text from the wrapper container element for now const mermaidSource = data.item.getAttribute('source'); const domElement = this.toDomElement(domDocument); domElement.innerHTML = mermaidSource; window.setTimeout(()=>{ // @todo: by the looks of it the domElement needs to be hooked to tree in order to allow for rendering. that._renderMermaid(domElement); }, 100); return domElement; } } /** * * @param {*} evt * @param {*} data * @param {*} conversionApi * @returns */ _sourceAttributeDowncast(evt, data, conversionApi) { // @todo: test whether the attribute was consumed. const newSource = data.attributeNewValue; const domConverter = this.editor.editing.view.domConverter; if (newSource) { const mermaidView = conversionApi.mapper.toViewElement(data.item); for (const child of mermaidView.getChildren()){ if (child.name === 'textarea' && child.hasClass('ck-mermaid__editing-view')) { const domEditingTextarea = domConverter.viewToDom(child, window.document); if (domEditingTextarea.value != newSource) { domEditingTextarea.value = newSource; } } else if (child.name === 'div' && child.hasClass('ck-mermaid__preview')) { // @todo: we could optimize this and not refresh mermaid if widget is in source mode. const domPreviewWrapper = domConverter.viewToDom(child, window.document); if (domPreviewWrapper) { domPreviewWrapper.innerHTML = newSource; domPreviewWrapper.removeAttribute('data-processed'); this._renderMermaid(domPreviewWrapper); } } } } } /** * * @private * @param {*} evt * @param {*} data * @param {*} conversionApi */ _mermaidUpcast(evt, data, conversionApi) { const viewCodeElement = data.viewItem; const hasPreElementParent = !viewCodeElement.parent || !viewCodeElement.parent.is('element', 'pre'); const hasCodeAncestors = data.modelCursor.findAncestor('code'); const { consumable, writer } = conversionApi; if (!viewCodeElement.hasClass('language-mermaid') || hasPreElementParent || hasCodeAncestors) { return; } if (!consumable.test(viewCodeElement, { name: true })) { return; } const mermaidSource = Array.from(viewCodeElement.getChildren()).filter((item)=>item.is('$text')).map((item)=>item.data).join(''); const mermaidElement = writer.createElement('mermaid', { source: mermaidSource, displayMode: 'split' }); // Let's try to insert mermaid element. if (!conversionApi.safeInsert(mermaidElement, data.modelCursor)) { return; } consumable.consume(viewCodeElement, { name: true }); conversionApi.updateConversionResult(mermaidElement, data); } /** * Renders Mermaid in a given `domElement`. Expect this domElement to have mermaid * source set as text content. * * @param {HTMLElement} domElement */ _renderMermaid(domElement) { mermaid.init(undefined, domElement); } } class MermaidToolbar extends Plugin { /** * @inheritDoc */ static get requires() { return [ WidgetToolbarRepository ]; } /** * @inheritDoc */ static get pluginName() { return 'MermaidToolbar'; } /** * @inheritDoc */ afterInit() { const editor = this.editor; const t = editor.t; const widgetToolbarRepository = editor.plugins.get(WidgetToolbarRepository); const mermaidToolbarItems = [ 'mermaidSourceView', 'mermaidSplitView', 'mermaidPreview', '|', 'mermaidInfo' ]; if (mermaidToolbarItems) { widgetToolbarRepository.register('mermaidToolbar', { ariaLabel: t('Mermaid toolbar'), items: mermaidToolbarItems, getRelatedElement: (selection)=>getSelectedElement(selection) }); } } } function getSelectedElement(selection) { const viewElement = selection.getSelectedElement(); if (viewElement && viewElement.hasClass('ck-mermaid__wrapper')) { return viewElement; } return null; } /* global window, document */ class MermaidUI extends Plugin { /** * @inheritDoc */ static get pluginName() { return 'MermaidUI'; } /** * @inheritDoc */ init() { this._addButtons(); } /** * Adds all mermaid-related buttons. * * @private */ _addButtons() { const editor = this.editor; this._addInsertMermaidButton(); this._addMermaidInfoButton(); this._createToolbarButton(editor, 'mermaidPreview', 'Preview', previewModeIcon); this._createToolbarButton(editor, 'mermaidSourceView', 'Source view', sourceModeIcon); this._createToolbarButton(editor, 'mermaidSplitView', 'Split view', splitModeIcon); } /** * Adds the button for inserting mermaid. * * @private */ _addInsertMermaidButton() { const editor = this.editor; const t = editor.t; const view = editor.editing.view; editor.ui.componentFactory.add('mermaid', (locale)=>{ const buttonView = new ButtonView(locale); const command = editor.commands.get('insertMermaidCommand'); buttonView.set({ label: t('Insert Mermaid diagram'), icon: insertMermaidIcon, tooltip: true }); buttonView.bind('isOn', 'isEnabled').to(command, 'value', 'isEnabled'); // Execute the command when the button is clicked. command.listenTo(buttonView, 'execute', ()=>{ const mermaidItem = editor.execute('insertMermaidCommand'); const mermaidItemViewElement = editor.editing.mapper.toViewElement(mermaidItem); view.scrollToTheSelection(); view.focus(); if (mermaidItemViewElement) { const mermaidItemDomElement = view.domConverter.viewToDom(mermaidItemViewElement, document); if (mermaidItemDomElement) { mermaidItemDomElement.querySelector('.ck-mermaid__editing-view').focus(); } } }); return buttonView; }); } /** * Adds the button linking to the mermaid guide. * * @private */ _addMermaidInfoButton() { const editor = this.editor; const t = editor.t; editor.ui.componentFactory.add('mermaidInfo', (locale)=>{ const buttonView = new ButtonView(locale); const link = 'https://ckeditor.com/blog/basic-overview-of-creating-flowcharts-using-mermaid/'; buttonView.set({ label: t('Read more about Mermaid diagram syntax'), icon: infoIcon, tooltip: true }); buttonView.on('execute', ()=>{ window.open(link, '_blank', 'noopener'); }); return buttonView; }); } /** * Adds the mermaid balloon toolbar button. * * @private * @param {module:core/editor/editor~Editor} editor * @param {String} name Name of the button. * @param {String} label Label for the button. * @param {String} icon The button icon. */ _createToolbarButton(editor, name, label, icon) { const t = editor.t; editor.ui.componentFactory.add(name, (locale)=>{ const buttonView = new ButtonView(locale); const command = editor.commands.get(`${name}Command`); buttonView.set({ label: t(label), icon, tooltip: true }); buttonView.bind('isOn', 'isEnabled').to(command, 'value', 'isEnabled'); // Execute the command when the button is clicked. command.listenTo(buttonView, 'execute', ()=>{ editor.execute(`${name}Command`); editor.editing.view.scrollToTheSelection(); editor.editing.view.focus(); }); return buttonView; }); } } class Mermaid extends Plugin { /** * @inheritDoc */ static get requires() { return [ MermaidEditing, MermaidToolbar, MermaidUI ]; } /** * @inheritDoc */ static get pluginName() { return 'Mermaid'; } } const icons = { infoIcon, insertMermaidIcon, previewModeIcon, splitModeIcon, sourceModeIcon }; export { Mermaid, icons }; //# sourceMappingURL=index.js.map