Score:1

Why does this custom CKEditor plugin produce the toolbarview-item-unavailable error?

pt flag

I have 2 modules in active development with the purpose of learning how to develop custom plugins for the Ckeditor5 wysiwyg.

On both modules, I am stuck on the following problem. The error pasted below is from the second module :

ckeditor5-dll.js?v=35.4.0:5 toolbarview-item-unavailable {item: 'testPlugin'} 
Read more: https://ckeditor.com/docs/ckeditor5/latest/support/error-codes.html#error-toolbarview-item-unavailable

Clearing indicated my plugin cannot be included in the toolbar.

With the module enabled, my testPlugin module is visible and added to my active toolbar in the text-formats configuration area as show below:

enter image description here

However, when on a node-edit form the plugin icon is not rendered in the toolbar due to the javascript error included above :

enter image description here

The full code for the second module is located here for your reference but I pasted some essential code below for anyone who wants to help or refer to this question in the future:

testPlugin.ckeditor5.yml

testplugin_embeddedContent:
  provider: testplugin
  ckeditor5:
    plugins:
      - embeddedContent.EmbeddedContent
    config:
#      embeddedContent:
      testPlugin:
        openDialog:
          func:
            name: Drupal.ckeditor5.openDialog
            invoke: false
        dialogSettings:
          height: 75%
          dialogClass: embedded-content-widget-modal
          title: Create embedded content
  drupal:
    label: Test Plugin
    elements:
      - <embedded-content>
      - <embedded-content data-plugin-config data-plugin-id>
    admin_library: testplugin/admin.embedded_content
    class: Drupal\testplugin\Plugin\CKEditor5Plugin\EmbeddedContent
    library: testplugin/embedded_content
    toolbar_items:
#      embeddedContent:
      testPlugin:
        label: My Test Plugin
    conditions:
      filter: testplugin

index.js:

import EmbeddedContent from "./embeddedcontent";

/**
 * @private
 */
export default {
  EmbeddedContent: EmbeddedContent
};

embeddedcontent.js:

import { Plugin } from 'ckeditor5/src/core';
import EmbeddedContentEditing from './embeddedcontentediting';
import EmbeddedContentUI from './embeddedcontentui';

/**
 * Main entry point to the embedded content.
 */
export default class EmbeddedContent extends Plugin {

  /**
   * @inheritdoc
   */
  static get requires() {
    return [
      EmbeddedContentEditing,
      EmbeddedContentUI
    ];
  }

  /**
   * @inheritdoc
   */
  static get pluginName() {
    // return 'embeddedContent';
    return 'testPlugin';
  }
}

embeddedcontentediting.js:

import {Plugin} from 'ckeditor5/src/core';
import {toWidget, Widget} from 'ckeditor5/src/widget';
import EmbeddedContentCommand from './embeddedcontentcommand';

/**
 * Embedded content editing functionality.
 */
export default class EmbeddedContentEditing extends Plugin {

  /**
   * @inheritdoc
   */
  static get requires() {
    return [Widget];
  }

  /**
   * @inheritdoc
   */
  init() {
    this.attrs = {
      embeddedContentPluginConfig: 'data-plugin-config',
      embeddedContentPluginId: 'data-plugin-id',
    };
    const options = this.editor.config.get('embeddedContent');
    if (!options) {
      return;
    }
    const {previewURL, themeError} = options;
    this.previewUrl = previewURL;
    this.themeError =
      themeError ||
      `
      <p>${Drupal.t(
        'An error occurred while trying to preview the embedded content. Please save your work and reload this page.',
      )}<p>
    `;

    this._defineSchema();
    this._defineConverters();

    this.editor.commands.add(
      'embeddedContent',
      new EmbeddedContentCommand(this.editor),
    );
  }

  /**
   * Fetches the preview.
   */
  async _fetchPreview(modelElement) {
    const query = {
      plugin_id: modelElement.getAttribute('embeddedContentPluginId'),
      plugin_config: modelElement.getAttribute('embeddedContentPluginConfig'),
    };
    const response = await fetch(
      `${this.previewUrl}?${new URLSearchParams(query)}`
    );
    if (response.ok) {
      return await response.text();
    }

    return this.themeError;
  }

  /**
   * Registers embeddedContent as a block element in the DOM converter.
   */
  _defineSchema() {
    const schema = this.editor.model.schema;
    schema.register('embeddedContent', {
      allowWhere: '$block',
      isObject: true,
      isContent: true,
      isBlock: true,
      allowAttributes: Object.keys(this.attrs),
    });
    this.editor.editing.view.domConverter.blockElements.push('embedded-content');
  }

  /**
   * Defines handling of drupal media element in the content lifecycle.
   *
   * @private
   */
  _defineConverters() {
    const conversion = this.editor.conversion;

    conversion
      .for('upcast')
      .elementToElement({
        view: {
          name: 'embedded-content',
        },
        model: 'embeddedContent',
      });

    conversion
      .for('dataDowncast')
      .elementToElement({
        model: 'embeddedContent',
        view: {
          name: 'embedded-content',
        },
      });
    conversion
      .for('editingDowncast')
      .elementToElement({
        model: 'embeddedContent',
        view: (modelElement, {writer}) => {
          const container = writer.createContainerElement('figure');
          return toWidget(container, writer, {
            label: Drupal.t('Embedded content'),
          });

        },
      })
      .add((dispatcher) => {
        const converter = (event, data, conversionApi) => {
          const viewWriter = conversionApi.writer;
          const modelElement = data.item;
          const container = conversionApi.mapper.toViewElement(data.item);
          const embeddedContent = viewWriter.createRawElement('div', {
            'data-embedded-content-preview': 'loading',
            'class': 'embedded-content-preview'
          });
          viewWriter.insert(viewWriter.createPositionAt(container, 0), embeddedContent);
          this._fetchPreview(modelElement).then((preview) => {
            if (!embeddedContent) {
              return;
            }
            this.editor.editing.view.change((writer) => {
              const embeddedContentPreview = writer.createRawElement(
                'div',
                {'class': 'embedded-content-preview', 'data-embedded-content-preview': 'ready'},
                (domElement) => {
                  domElement.innerHTML = preview;
                },
              );
              writer.insert(writer.createPositionBefore(embeddedContent), embeddedContentPreview);
              writer.remove(embeddedContent);
            });
          });
        };
        dispatcher.on('attribute:embeddedContentPluginId:embeddedContent', converter);
        return dispatcher;
      });

    Object.keys(this.attrs).forEach((modelKey) => {
      const attributeMapping = {
        model: {
          key: modelKey,
          name: 'embeddedContent',
        },
        view: {
          name: 'embedded-content',
          key: this.attrs[modelKey],
        },
      };
      conversion.for('dataDowncast').attributeToAttribute(attributeMapping);
      conversion.for('upcast').attributeToAttribute(attributeMapping);
    });
  }

  /**
   * @inheritdoc
   */
  static get pluginName() {
    return 'embeddedContentEditing';
  }
}

embeddedcontentui.js:

import { Plugin } from 'ckeditor5/src/core';
import { ButtonView } from 'ckeditor5/src/ui';
import EmbeddedContentIcon from '../theme/icons/embeddedcontent.svg';
import {DomEventObserver} from "ckeditor5/src/engine";

/**
 * Ckeditor5 doesn't support double click out of the box.
 * Register it here so we can use it.
 *
 * @Todo Replace double click with a balloon style popup menu to
 *   edit the embedded content item.
 */
class DoubleClickObserver extends DomEventObserver {
  constructor( view ) {
    super( view );
    this.domEventType = 'dblclick';
  }

  onDomEvent( domEvent ) {
    this.fire( domEvent.type, domEvent );
  }
}

/**
 * Provides the embedded content button and editing.
 */
export default class EmbeddedContentUI extends Plugin {

  init() {
    const editor = this.editor;
    const options = this.editor.config.get('embeddedContent');
    if (!options) {
      return;
    }

    const { dialogURL, openDialog, dialogSettings = {} } = options;
    if (!dialogURL || typeof openDialog !== 'function') {
      return;
    }
    editor.ui.componentFactory.add('embeddedContent', (locale) => {
      const command = editor.commands.get('embeddedContent');
      const buttonView = new ButtonView(locale);

      buttonView.set({
        label: Drupal.t('Embedded content'),
        icon: EmbeddedContentIcon,
        tooltip: true,
      });


      // Bind the state of the button to the command.
      buttonView.bind('isOn', 'isEnabled').to(command, 'value', 'isEnabled');

      this.listenTo(buttonView, 'execute', () => {
        openDialog(
          dialogURL,
          ({ attributes }) => {
            editor.execute('embeddedContent', attributes);
          },
          dialogSettings,
        );
      });

      return buttonView;
    });

    const view = editor.editing.view;
    const viewDocument = view.document;

    view.addObserver( DoubleClickObserver );

    editor.listenTo( viewDocument, 'dblclick', ( evt, data ) => {
      const modelElement = editor.editing.mapper.toModelElement( data.target);
      if(modelElement && typeof modelElement.name !== 'undefined' && modelElement.name === 'embeddedContent'){
        const query = {
          plugin_id: modelElement.getAttribute('embeddedContentPluginId'),
          plugin_config: modelElement.getAttribute('embeddedContentPluginConfig'),
        };
        openDialog(
          `${dialogURL}?${new URLSearchParams(query)}`,
          ({ attributes }) => {
            editor.execute('embeddedContent', attributes);
          },
          dialogSettings,
        );
      }
    } );
  }
}

embeddedcontentcommand.js:

import { Command } from 'ckeditor5/src/core';

/**
 * Creates embedded content
 */
function createEmbeddedContent(writer, attributes) {
  return writer.createElement('embeddedContent', attributes);
}

/**
 * Command for inserting <embedded-content> tag into ckeditor.
 */
export default class EmbeddedContentCommand extends Command {
  execute(attributes) {
    const embeddedContentEditing = this.editor.plugins.get('embeddedContentEditing');

    // Create object that contains supported data-attributes in view data by
    // flipping `DrupalMediaEditing.attrs` object (i.e. keys from object become
    // values and values from object become keys).
    const dataAttributeMapping = Object.entries(embeddedContentEditing.attrs).reduce(
      (result, [key, value]) => {
        result[value] = key;
        return result;
      },
      {},
    );

    // \Drupal\media\Form\EditorMediaDialog returns data in keyed by
    // data-attributes used in view data. This converts data-attribute keys to
    // keys used in model.
    const modelAttributes = Object.keys(attributes).reduce(
      (result, attribute) => {
        if (dataAttributeMapping[attribute]) {
          result[dataAttributeMapping[attribute]] = attributes[attribute];
        }
        return result;
      },
      {},
    );

    this.editor.model.change((writer) => {
      this.editor.model.insertContent(
        createEmbeddedContent(writer, modelAttributes),
      );
    });
  }

  refresh() {
    const model = this.editor.model;
    const selection = model.document.selection;
    const allowedIn = model.schema.findAllowedParent(
      selection.getFirstPosition(),
      'embeddedContent',
    );
    this.isEnabled = allowedIn !== null;
  }
}

I would appreciate some help understanind what I'm missing or doing wrong.

Thanks in advance.

id flag
Is the information at https://ckeditor.com/docs/ckeditor5/latest/support/error-codes.html#error-toolbarview-item-unavailable unhelpful?
pt flag
Hi @cilefen. I did look at the documentation but it does not really offer a solution but only mentions that the plugin fails to load for some reason - which I already know
cn flag
Please include minimal code to reproduce the problem _in the question body_, rather than linking to a full set of code. That question will just get closed otherwise, as this question will fail to be useful in the future when you remove the broken code from the external site. This is the sort of question that ends up being very useful for future developers so let's get an exemplary canonical version of it created now for the future generations :)
pt flag
Thanks @Clive. I included some of the essential code. Apologies, it goes beyond minimal
Score:1
mf flag

I would recommend keeping your class name and plugin name consistent. In embeddedcontent.js the class is EmbeddedContent but the plugin is testPlugin. It looks like you have commented-out lines in your ckeditor5.ymlthat would declare the plugin as embeddedContent, try committing to that and removing testPlugin.

mangohost

Post an answer

Most people don’t grasp that asking a lot of questions unlocks learning and improves interpersonal bonding. In Alison’s studies, for example, though people could accurately recall how many questions had been asked in their conversations, they didn’t intuit the link between questions and liking. Across four studies, in which participants were engaged in conversations themselves or read transcripts of others’ conversations, people tended not to realize that question asking would influence—or had influenced—the level of amity between the conversationalists.