mirror of
				https://github.com/zadam/trilium.git
				synced 2025-11-04 05:28:59 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			277 lines
		
	
	
		
			9.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			277 lines
		
	
	
		
			9.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
/**
 | 
						|
 * @module mermaid/mermaidediting
 | 
						|
 */
 | 
						|
 | 
						|
import { debounce } from 'lodash-es';
 | 
						|
 | 
						|
import MermaidPreviewCommand from './commands/mermaidPreviewCommand.js';
 | 
						|
import MermaidSourceViewCommand from './commands/mermaidSourceViewCommand.js';
 | 
						|
import MermaidSplitViewCommand from './commands/mermaidSplitViewCommand.js';
 | 
						|
import InsertMermaidCommand from './commands/insertMermaidCommand.js';
 | 
						|
import { DowncastAttributeEvent, DowncastConversionApi, EditorConfig, ModelElement, EventInfo, ModelItem, ModelNode, Plugin, toWidget, UpcastConversionApi, UpcastConversionData, ViewElement, ViewText, ViewUIElement } from 'ckeditor5';
 | 
						|
 | 
						|
// Time in milliseconds.
 | 
						|
const DEBOUNCE_TIME = 300;
 | 
						|
 | 
						|
/* global window */
 | 
						|
 | 
						|
type DowncastConversionData = DowncastAttributeEvent["args"][0];
 | 
						|
 | 
						|
export default class MermaidEditing extends Plugin {
 | 
						|
 | 
						|
	private _config!: EditorConfig["mermaid"];
 | 
						|
	private mermaid?: MermaidInstance;
 | 
						|
 | 
						|
	/**
 | 
						|
	 * @inheritDoc
 | 
						|
	 */
 | 
						|
	static get pluginName() {
 | 
						|
		return 'MermaidEditing' as const;
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * @inheritDoc
 | 
						|
	 */
 | 
						|
	init() {
 | 
						|
		this._registerCommands();
 | 
						|
		this._defineConverters();
 | 
						|
		this._config = this.editor.config.get("mermaid");
 | 
						|
	}
 | 
						|
 | 
						|
	/**
 | 
						|
	 * @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'
 | 
						|
			} )
 | 
						|
		} );
 | 
						|
	}
 | 
						|
 | 
						|
	_mermaidDataDowncast( evt: EventInfo, data: DowncastConversionData, conversionApi: DowncastConversionApi ) {
 | 
						|
		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 as ModelItem ) );
 | 
						|
		// 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'
 | 
						|
		} ) as any;
 | 
						|
		const pre = writer.createContainerElement( 'pre', {
 | 
						|
			spellcheck: 'false'
 | 
						|
		} ) as any;
 | 
						|
		const sourceTextNode = writer.createText( data.item.getAttribute( 'source' ) as string);
 | 
						|
 | 
						|
		writer.insert( model.createPositionAt( code, 'end' ) as any, sourceTextNode );
 | 
						|
		writer.insert( model.createPositionAt( pre, 'end' ) as any, code );
 | 
						|
		writer.insert( targetViewPosition, pre );
 | 
						|
		mapper.bindElements( data.item as ModelElement, code as ViewElement );
 | 
						|
	}
 | 
						|
 | 
						|
	_mermaidDowncast( evt: EventInfo, data: DowncastConversionData, conversionApi: DowncastConversionApi ) {
 | 
						|
		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 as ModelItem ) );
 | 
						|
 | 
						|
		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 );
 | 
						|
 | 
						|
		//@ts-expect-error
 | 
						|
		writer.insert( writer.createPositionAt( wrapper, 'start' ), previewContainer );
 | 
						|
		//@ts-expect-error
 | 
						|
		writer.insert( writer.createPositionAt( wrapper, 'start' ), editingContainer );
 | 
						|
 | 
						|
		writer.insert( targetViewPosition, wrapper );
 | 
						|
 | 
						|
		mapper.bindElements( data.item as ModelElement, wrapper );
 | 
						|
 | 
						|
		return toWidget( wrapper, writer, {
 | 
						|
			label: t( 'Mermaid widget' ),
 | 
						|
			hasSelectionHandle: true
 | 
						|
		} );
 | 
						|
 | 
						|
		function createEditingTextarea(this: ViewUIElement, domDocument: Document ) {
 | 
						|
			const domElement = this.toDomElement( domDocument ) as HTMLElement as HTMLInputElement;
 | 
						|
 | 
						|
			domElement.value = data.item.getAttribute( 'source' ) as string;
 | 
						|
 | 
						|
			const debouncedListener = debounce( event => {
 | 
						|
				editor.model.change( writer => {
 | 
						|
					writer.setAttribute( 'source', event.target.value, data.item as ModelNode );
 | 
						|
				} );
 | 
						|
			}, 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 as ModelNode, 'on' ) );
 | 
						|
				}
 | 
						|
			}, true );
 | 
						|
 | 
						|
			return domElement;
 | 
						|
		}
 | 
						|
 | 
						|
		function createMermaidPreview(this: ViewUIElement,  domDocument: Document ) {
 | 
						|
			// Taking the text from the wrapper container element for now
 | 
						|
			const mermaidSource = data.item.getAttribute( 'source' ) as string;
 | 
						|
			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;
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	_sourceAttributeDowncast( evt: EventInfo, data: DowncastConversionData, conversionApi: DowncastConversionApi ) {
 | 
						|
		// @todo: test whether the attribute was consumed.
 | 
						|
		const newSource = data.attributeNewValue as string;
 | 
						|
		const domConverter = this.editor.editing.view.domConverter;
 | 
						|
 | 
						|
		if ( newSource ) {
 | 
						|
			const mermaidView = conversionApi.mapper.toViewElement( data.item as ModelElement );
 | 
						|
			if (!mermaidView) {
 | 
						|
				return;
 | 
						|
			}
 | 
						|
 | 
						|
			for ( const _child of mermaidView.getChildren() ) {
 | 
						|
				const child = _child as ViewElement;
 | 
						|
				if ( child.name === 'textarea' && child.hasClass( 'ck-mermaid__editing-view' ) ) {
 | 
						|
					// Text & HTMLElement & ModelNode & DocumentFragment
 | 
						|
					const domEditingTextarea = domConverter.viewToDom(child) as HTMLElement as HTMLInputElement;
 | 
						|
 | 
						|
					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);
 | 
						|
 | 
						|
					if ( domPreviewWrapper ) {
 | 
						|
						domPreviewWrapper.innerHTML = newSource;
 | 
						|
						domPreviewWrapper.removeAttribute( 'data-processed' );
 | 
						|
 | 
						|
						this._renderMermaid( domPreviewWrapper );
 | 
						|
					}
 | 
						|
				}
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	_mermaidUpcast( evt: EventInfo, data: UpcastConversionData, conversionApi: UpcastConversionApi ) {
 | 
						|
		const viewCodeElement = data.viewItem as ViewElement;
 | 
						|
		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 as ViewText).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.
 | 
						|
	 */
 | 
						|
	async _renderMermaid( domElement: HTMLElement ) {
 | 
						|
		if (!window.mermaid && typeof this._config?.lazyLoad === "function") {
 | 
						|
			this.mermaid = await this._config.lazyLoad();
 | 
						|
		}
 | 
						|
 | 
						|
		this.mermaid?.init( this._config?.config ?? {}, domElement );
 | 
						|
	}
 | 
						|
}
 |