Search code examples
ckeditorckeditor5ckeditor5-reactckeditor5-plugin

CKEditor 5 Downcast Converter for Paragraph To Wrap Text in Span


We're trying to write a 'paragraph' model downcast converter that will wrap all text nodes in a span, inside the paragraph p block element.

For example we have the following:

function AddSpansToText(editor) {
  editor.conversion.for('downcast').add(dispatcher => {
    dispatcher.on('insert:paragraph', (evt, data, conversionApi) => {
      // Remember to check whether the change has not been consumed yet and consume it.
      if (!conversionApi.consumable.consume(data.item, 'insert')) {
        return;
      }

      const { writer, mapper } = conversionApi

      // Translate the position in the model to a position in the view.
      const viewPosition = mapper.toViewPosition(data.range.start);

      // Create a <p> element that will be inserted into the view at the `viewPosition`.
      const div = writer.createContainerElement('p', { class: 'data-block' });
      const span = writer.createAttributeElement('span', { class: 'data-text' });
      writer.insert(writer.createPositionAt(div, 0), span);

      // Bind the newly created view element to the model element so positions will map accordingly in the future.
      mapper.bindElements(data.item, div);

      // Add the newly created view element to the view.
      writer.insert(viewPosition, div);

      // Remember to stop the event propagation.
      evt.stop();
    });
  });
}

We then register the function above as an extra plugin in the config settings as...

extraPlugins: [AddSpansToText],

This is close, however, we're not able to get the text node to appear inside the span, it appears as a peer, as ...

<p>
 Text here....
 <span></span>
</p>

We can't seem to map the model to the new view position.

Suggestions as to what we might be doing wrong greatly appreciated.


Solution

  • For anyone else looking for this, and based loosely on this example here... https://ckeditor.com/docs/ckeditor5/latest/framework/guides/deep-dive/conversion/custom-element-conversion.html

    ... here's what I've come up with...

    /**
     * Helper method to map model to view position
     * 
     * @param {*} view 
     */
    function createModelToViewPositionMapper(view) {
      return (evt, data) => {
        const modelPosition = data.modelPosition;
        const parent = modelPosition.parent;
    
        // Only the mapping of positions that are directly in
        // the <paragraph> model element should be modified.
        if (!parent || !parent.is('element', 'paragraph')) {
          return;
        }
    
        // Get the mapped view element <div class="data-block">.
        const viewElement = data.mapper.toViewElement(parent);
    
        // Find the <span class="data-text"> in it.
        const viewContentElement = findContentViewElement( view, viewElement );
    
        // Translate the model position offset to the view position offset.
        data.viewPosition = data.mapper.findPositionIn( viewContentElement, modelPosition.offset );
      };
    }
    
    /**
     * Helper method to find child span at correct curser offset
     * 
     * @param {*} editingView 
     * @param {*} viewElement 
     * @returns <span class="data-text"> nested in the parent view structure. 
     */
    function findContentViewElement( editingView, viewElement ) {
      for ( const value of editingView.createRangeIn( viewElement ) ) {
          if ( value.item.is( 'element', 'span' ) && value.item.hasClass( 'data-text' ) ) {
              return value.item;
          }
      }
    }
    
    /**
     * Paragraph model downcast converter to wrap all text nodes in 
     * inline span elements
     * 
     * @param {*} editor 
     */
    function ParagraphConverter(editor) {
      editor.conversion.for('downcast').add(dispatcher => {
        dispatcher.on('insert:paragraph', (evt, data, conversionApi) => {
          // Remember to check whether the change has not been consumed yet and consume it.
          if (!conversionApi.consumable.consume(data.item, 'insert')) {
            return;
          }
          const { writer, mapper } = conversionApi
    
          // Translate the position in the model to a position in the view.
          const viewPosition = mapper.toViewPosition(data.range.start);
    
          // Create a <div> element that will be inserted into the view at the `viewPosition`.
          const div = writer.createContainerElement('div', { class: 'data-block' });
          
          // Create the <span> element that will be inserted into the div
          const span = writer.createEditableElement('span', { class: 'data-text' });
          writer.insert(writer.createPositionAt(div, 0), span);
    
          // Bind the newly created view element to the model element so positions will map accordingly in the future.
          mapper.bindElements(data.item, div);
    
          // Add the newly created view element to the view.
          writer.insert(viewPosition, div);
    
          // Remember to stop the event propagation.
          evt.stop();
        });
      });
    
      // Dynamic mapping for model to view and curser position with correct offset
      editor.editing.mapper.on( 'modelToViewPosition', createModelToViewPositionMapper( editor.editing.view ) );
      editor.data.mapper.on( 'modelToViewPosition', createModelToViewPositionMapper( editor.editing.view ) );
    }