Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding a custom widget to the Style dropdown #16061

Open
bkosborne opened this issue Mar 19, 2024 · 5 comments
Open

Adding a custom widget to the Style dropdown #16061

bkosborne opened this issue Mar 19, 2024 · 5 comments
Labels
domain:dx This issue reports a developer experience problem or possible improvement. domain:integration-dx This issue reports a problem with the developer experience when integrating CKEditor into a system. follow-up:docs follow-up:how-to package:html-support package:style type:improvement This issue reports a possible enhancement of an existing feature.

Comments

@bkosborne
Copy link

bkosborne commented Mar 19, 2024

I've been struggling to figure out how a custom widget plugin I've created (which is more or less a clone of the block widget created in the tutorial) can become compatible with applying styles via the Style plugin.

Just like the tutorial, my view downcast outputs a <section> element as the widget root. I have a style definition to add a class to section elements. However, when I click the element in the editor, the styles dropdown is not active and I cannot select the element.

It seems like I may need to create my own integration with style, like what was done for link and table - but browsing through the source code of those integrations I don't really know where to begin or how implementing similar code would work for me. I'm not sure why only a few plugins require custom integrations. What's allowing "paragraph" plugin to receive styles for example? And why is what I'm doing somehow more complicated?

It seems that the Style plugin is closely related to the GHS plugin - in that when a style is applied to a model of various elements, I notice that a model attribute like "htmlH2Attributes" or "htmlTableAttributes" is created on the outer element, but I can't find the code that's actually doing this.

I'm just feeling quite lost here. Any help is appreciated. Thank you.

Here's the code for my plugin that extends the schema and creates the downcast/upcast converters:

import { Plugin } from 'ckeditor5/src/core';
import { toWidget, toWidgetEditable } from 'ckeditor5/src/widget';
import { Widget } from 'ckeditor5/src/widget';
import InsertCalloutCommand from './insertcalloutcommand';
import AlignCalloutCommand from "./aligncommand";

/**
 * CKEditor 5 plugins do not work directly with the DOM. They are defined as
 * plugin-specific data models that are then converted to markup that
 * is inserted in the DOM.
 *
 * CKEditor 5 internally interacts with callout as this model:
 * <callout>
 *    <calloutTitle></calloutTitle>
 *    <calloutDescription></calloutDescription>
 * </callout>
 *
 * Which is converted for the browser/user as this markup
 * <section class="cke-callout">
 *   <div class="cke-callout-title"></h1>
 *   <div class="cke-callout-content"></div>
 * </section>
 *
 * This file has the logic for defining the callout model, and for how it is
 * converted to standard DOM markup.
 */
export default class CalloutEditing extends Plugin {
  static get requires() {
    return [Widget];
  }

  init() {
    this._defineSchema();
    this._defineConverters();
    this.editor.commands.add('insertCallout', new InsertCalloutCommand(this.editor));
    this.editor.commands.add('alignCallout', new AlignCalloutCommand(this.editor));
  }

  /*
   * This registers the structure that will be seen by CKEditor 5 as
   * <callout>
   *    <calloutTitle></calloutTitle>
   *    <calloutDescription></calloutDescription>
   * </callout>
   *
   * The logic in _defineConverters() will determine how this is converted to
   * markup.
   */
  _defineSchema() {
    // Schemas are registered via the central `editor` object.
    const schema = this.editor.model.schema;

    schema.register('callout', {
      // Behaves like a self-contained object (e.g. an image).
      isObject: true,
      // Allow in places where other blocks are allowed (e.g. directly in the root).
      allowWhere: '$block',
      allowAttributes: ['align', 'class']
      inheritAllFrom: '$block',
    });

    schema.register('calloutTitle', {
      // This creates a boundary for external actions such as clicking and
      // and keypress. For example, when the cursor is inside this box, the
      // keyboard shortcut for "select all" will be limited to the contents of
      // the box.
      isLimit: true,
      // This is only to be used within callout.
      allowIn: 'callout',
      allowChildren: '$text',
    });

    // Prevent all attributes from being applied to callout titles. We just
    // allow plain text. This is how our plugin worked in CKE4, so just
    // carrying this forward. Not sure it was ever a requirement that we
    // restrict it this way.
    schema.addAttributeCheck((context, attributeName) => {
      for (const name of context.getNames()) {
        if (name === 'calloutTitle') {
          return false;
        }
      }
    });

    schema.register('calloutDescription', {
      isLimit: true,
      allowIn: 'callout',
      allowContentOf: '$root',
    });

    schema.addChildCheck((context, childDefinition) => {
      // Disallow callout inside calloutDescription.
      if (
        context.endsWith('calloutDescription') &&
        childDefinition.name === 'callout'
      ) {
        return false;
      }
    });
  }

  /**
   * Converters determine how CKEditor 5 models are converted into markup and
   * vice-versa.
   */
  _defineConverters() {
    // Converters are registered via the central editor object.
    const { conversion } = this.editor;

    // Upcast Converters: determine how existing HTML is interpreted by the
    // editor. These trigger when an editor instance loads.
    //
    // If <section class="cke-callout"> is present in the existing markup
    // processed by CKEditor, then CKEditor recognizes and loads it as a
    // <callout> model.
    conversion.for('upcast').elementToElement({
      model: ( viewElement, { writer } ) => {
        let align = 'full';
        if (viewElement.hasClass('align-left')) {
          align = 'left';
        }
        else if (viewElement.hasClass('align-right')) {
          align = 'right';
        }
        return writer.createElement('callout', {
          align: align
        })
      },
      view: {
        name: 'section',
        classes: 'cke-callout',
      },
    });

    // If <div class="cke-callout-title"> is present in the existing markup
    // processed by CKEditor, then CKEditor recognizes and loads it as a
    // <calloutTitle> model, provided it is a child element of <callout>,
    // as required by the schema.
    conversion.for('upcast').elementToElement({
      model: 'calloutTitle',
      view: {
        name: 'div',
        classes: 'cke-callout-title',
      },
    });

    // If <div class="cke-callout-content"> is present in the existing markup
    // processed by CKEditor, then CKEditor recognizes and loads it as a
    // <calloutDescription> model, provided it is a child element of
    // <callout>, as required by the schema.
    conversion.for('upcast').elementToElement({
      model: 'calloutDescription',
      view: {
        name: 'div',
        classes: 'cke-callout-content',
      },
    });

    // Data Downcast Converters: converts stored model data into HTML.
    // These trigger when content is saved.
    //
    // Instances of <callout> are saved as
    // <section class="cke-callout">{{inner content}}</section>.
    conversion.for('dataDowncast').elementToElement({
      model: 'callout',
      view: (modelElement, { writer: viewWriter }) => {
        let classes = ['cke-callout'];
        const align = modelElement.getAttribute('align');
        if (align) {
          classes.push('align-' + align);
        }
        return viewWriter.createContainerElement('section', {
          class: classes.join(' ')
        })
      }
    });

    // Instances of <calloutTitle> are saved as
    // <div class="cke-callout-title">{{inner content}}</div>.
    conversion.for('dataDowncast').elementToElement({
      model: 'calloutTitle',
      view: {
        name: 'div',
        classes: 'cke-callout-title',
      },
    });

    // Instances of <calloutDescription> are saved as
    // <div class="cke-callout-body">{{inner content}}</div>.
    conversion.for('dataDowncast').elementToElement({
      model: 'calloutDescription',
      view: {
        name: 'div',
        classes: 'cke-callout-content',
      },
    });

    // Editing Downcast Converters. These render the content to the user for
    // editing, i.e. this determines what gets seen in the editor. These trigger
    // after the Data Upcast Converters, and are re-triggered any time there
    // are changes to any of the models' properties.
    //
    // Convert the <callout> model into a container widget in the editor UI.
    conversion.for('editingDowncast').elementToElement({
      model: 'callout',
      view: (modelElement, { writer: viewWriter }) => {
        let classes = ['cke-callout'];
        const align = modelElement.getAttribute('align');
        if (align) {
          classes.push('align-' + align);
        }

        const section = viewWriter.createContainerElement('section', {
          class: classes.join(' ')
        });

        viewWriter.setCustomProperty('callout', true, section);

        return toWidget(section, viewWriter, {
          label: 'callout widget',
          hasSelectionHandle: true
        });
      },
    });

    // Ensure that changing the alignment will trigger a re-draw.
    // Not sure why this is really needed. Seems like the editingDowncast for
    // the callout should handle this, but it's not triggered when the align
    // attribute on the model is changed. Only this is triggered.
    conversion.for('editingDowncast').attributeToAttribute({
      model: {
        name: 'callout',
        key: 'align',
      },
      view: (modelAttributeValue) => {
        return {
          key: 'class',
          value: 'align-' + modelAttributeValue
        }
      }
    });

    // Convert the <calloutTitle> model into an editable <div> widget.
    conversion.for('editingDowncast').elementToElement({
      model: 'calloutTitle',
      view: (modelElement, { writer: viewWriter }) => {
        const div = viewWriter.createEditableElement('div', {
          class: 'cke-callout-title',
        });
        return toWidgetEditable(div, viewWriter);
      },
    });

    // Convert the <calloutDescription> model into an editable <div> widget.
    conversion.for('editingDowncast').elementToElement({
      model: 'calloutDescription',
      view: (modelElement, { writer: viewWriter }) => {
        const div = viewWriter.createEditableElement('div', {
          class: 'cke-callout-content',
        });
        return toWidgetEditable(div, viewWriter);
      },
    });
  }
}
@bkosborne bkosborne added the type:question This issue asks a question (how to...). label Mar 19, 2024
@arkjoseph
Copy link

following. I just noticed this as well

@bkosborne
Copy link
Author

I've made some progress on this by reading thru the source code for the Style plugin and the General HTML Support plugin. Style uses GHS to do its implementing. I believe I need to add "htmlCalloutAttributes" as an allowed attribute on my custom top level "callout" model. I think that's all I need to do to make my model compatible with GHS (though I may need to write my own upcast/downcast converters, not sure yet).

Then I need to make the widget compatible with the Styles plugin similar to what Table had to do here.

Will keep working at it...

@justcaldwell
Copy link

Following -- I could use this functionality as well!

@bkosborne
Copy link
Author

I've crawled out of the depths of the CKE5 source code and am victorious.

Here's what needs to happen to make a simple block widget, similar to the one in the tutorial, be compatible with the Styles plugin.

For context, I've created my own block widget plugin based of that tutorial that I call "callout". It's wrapped with an HTML "section" element. My goal was to allow a Style definition that targets the "section" element to be used on my widget, adding CSS classes to it.

Hopefully this helps others. It would be nice to have the block widget plugin example expanded to include integration with Styles.

Allowing the GHS attribute on the model

When registering the schema for the top level "callout" model, I needed to indicate that it's a block and to allow the attribute "htmlSectionAttributes":

    schema.register('callout', {
      // Behaves like a self-contained object (e.g. an image).
      isObject: true,
      // This allows block styles to be applied to it.
      isBlock: true,
      // Allow in places where other blocks are allowed (e.g. directly in the root).
      allowWhere: '$block',
      // We need to allow the GHS attribute that the Style plugin uses when
      // applying CSS classes from an applied section style.
      allowAttributes: ['htmlSectionAttributes']
    });

The "htmlSectionAttributes" attribute comes from the GHS (General HTML Support) plugin. This is an attribute that it uses to store things like custom "style" attribute values and a list of CSS classes. The Style plugin interacts with GHS to add and remove classes from this attribute.

It's very important for block widgets to have isBlock: true as well, or the block-level "section" style definition won't ever be applicable to the widget and you won't be able to select it from the Styles drop-down.

Adding a downcast for the GHS attribute

By default, I guess the GHS plugin doesn't know how to take its attribute and downcast it to the view. I'm not really sure why, as the code that's required to make it work seems very generic and there's nothing in it that's specific to my plugin. But here we go:

    conversion.for('downcast').add((dispatcher) => {
      dispatcher.on('attribute:htmlSectionAttributes:callout', (evt, data, conversionApi) => {
        // Tell the conversion API we're handling this attribute conversion
        // so nothing else tries to do something with it.
        if (!conversionApi.consumable.consume( data.item, evt.name )) {
          return;
        }

        const { attributeOldValue, attributeNewValue } = data;
        const viewCodeElement = conversionApi.mapper.toViewElement(data.item);

        updateViewAttributes(
          conversionApi.writer,
          attributeOldValue,
          attributeNewValue,
          viewCodeElement
        );
      });
    });

Looking at the existing plugin integrations written for the GHS plugin helped me here.

What's very odd is that I didn't also need to define an upcast converter from the view to the model. It just ... works? This is strange as other examples I looked at did define the upcast converter.

Integration with the Style plugin

Okay, so now I've integrated my block widget with the GHS plugin, but I still need to integrate with the Style plugin. Without some extra code, section style definitions were not selectable from the drop-down.

I mostly looked at the existing code for integrating the Table and List plugins with the Style plugin. This was the important bit I needed in my plugin's main init() function:

init() {
    const editor = this.editor;

    this._styleUtils = editor.plugins.get('StyleUtils');
    this._htmlSupport = editor.plugins.get('GeneralHtmlSupport');

    this.listenTo(this._styleUtils, 'isStyleEnabledForBlock', (evt, [definition, block]) => {
      if (['section'].includes(definition.element) && block.name === 'callout') {
        evt.return = true;
        evt.stop();
      }
    }, { priority: 'high' });

    // This doesn't seem strictly necessary as there's nothing in here specific
    // to our model, but this was in the style integration code for list
    // plugin, so might as well carry it over too.
    this.listenTo(this._styleUtils, 'isStyleActiveForBlock', (evt, [definition, block]) => {
      const attributeName = this._htmlSupport.getGhsAttributeNameForElement(definition.element);
      const ghsAttributeValue = block.getAttribute(attributeName);

      if (this._styleUtils.hasAllClasses(ghsAttributeValue, definition.classes)) {
        evt.return = true;
        evt.stop();
      }
    }, { priority: 'high' });

    this.listenTo(this._styleUtils, 'getAffectedBlocks', (evt, [definition, block]) => {
      if (['section'].includes(definition.element) && block.name === 'callout') {
        evt.return = [block];
        evt.stop();
      }
    }, { priority: 'high' });
  }

This is what allows a style registered for the "section" HTML element to be used on my callout widget (which is wrapped with a "section").

@Witoso Witoso added domain:dx This issue reports a developer experience problem or possible improvement. package:html-support package:style domain:integration-dx This issue reports a problem with the developer experience when integrating CKEditor into a system. follow-up:how-to follow-up:docs labels Mar 25, 2024
@Witoso
Copy link
Member

Witoso commented Mar 25, 2024

Hi @bkosborne, and sorry for the delayed response, I was out for a couple of days.

Glad you worked out the solution. Creating an integration is a way to go, but the DX of the API is not there. I wasn't here when the Style was created, but AFAIK, it was thought as styling general HTML elements, and didn't implement ways to style specific ones. The table/list integration is there because those are the most complex features, which representation in the model differs a lot from the HTML.

Another discussion point is when styling via the dropdown is better than implementation of a toolbar on a widget that provides a bit more context-aware UI. But most likely, we should allow integrators to decide, and have both options.

I will change this to the feature improvement issue. Feedback is always welcome!

@Witoso Witoso added type:improvement This issue reports a possible enhancement of an existing feature. and removed type:question This issue asks a question (how to...). labels Mar 25, 2024
@Witoso Witoso changed the title How can a custom widget become compatible with the Styles plugin? Adding a custom widget to the Style dropdown Mar 25, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
domain:dx This issue reports a developer experience problem or possible improvement. domain:integration-dx This issue reports a problem with the developer experience when integrating CKEditor into a system. follow-up:docs follow-up:how-to package:html-support package:style type:improvement This issue reports a possible enhancement of an existing feature.
Projects
None yet
Development

No branches or pull requests

4 participants