Skip to content

How to: custom UI component

Iiro Krankka edited this page Apr 15, 2021 · 10 revisions

Super Editor ships with a number of built-in UI components to render things like paragraphs, list items, images, and horizontal rules.

You may want a different visual representation for an existing type of content, or you may want to add more functionality to an existing component, or you may want to provide a component for a new type of content. All of these goals are achieved by defining a new component Widget and building that Widget in a new ComponentBuilder function. This guide describes how to do that.

Let's say that you want to introduce a custom component that shows hint text for the very first paragraph when there is no content in a document. These are the steps you might take.

Customize your editor

Add a custom ComponentBuilder to the top of the list of ComponentBuilders given to your Editor (you'll implement the ComponentBuilder in the next step).

Editor.custom(
  editor: DocumentEditor(document: doc),
  componentBuilders: [
    firstParagraphHintComponentBuilder,
    ...defaultComponentBuilders,
  ],
);

In this example, an Editor widget is built with a custom list of componentBuilders. This list of ComponentBuilders is responsible for building every possible widget that might appear in the DocumentLayout.

firstParagraphHintComponentBuilder is the new ComponentBuilder that is added to display hint text when the document is empty.

Notice that a list of defaultComponentBuilders are added after firstParagraphHintComponentBuilder. The defaultComponentBuilders are the standard component builders for the Editor. If you don't include the defaultComponentBuilders in the list then the Editor will not know how to build all of the standard visual components.

Define a new ComponentBuilder

A ComponentBuilder function is responsible for creating a Widget that renders a given piece of content. For example, by default, a TextComponent is used to render a ParagraphNode.

Return null if the content doesn't apply to this builder

For every piece of content in a Document, the list of ComponentBuilders is asked to provide a Widget. The first ComponentBuilder in the list to return a Widget is the builder that's used for that content. In other words, all of the ComponentBuilders are in a priority list.

Due to the ComponentBuilder priority list, whenever a builder is asked to build a Widget for content or a situation that doesn't apply, the builder needs to return null. Therefore, a good first step when building a ComponentBuilder is to filter out all the situations that don't apply.

Widget firstParagraphHintComponentBuilder(ComponentContext componentContext) {
  // We only care about paragraphs.
  final paragraphNode = componentContext.documentNode;
  if (paragraphNode is! ParagraphNode) {
    return null;
  }

  // We only care about the situation where the Document is empty. In this case
  // a Document is "empty" when there is only a single ParagraphNode.
  if (componentContext.document.nodes.length > 1) {
    return null;
  }

  // We only care about the situation where the first ParagraphNode is empty.
  if (paragraphNode.text.text.isNotEmpty()) {
    return null;
  }

  // We only want to show a hint component if the first ParagraphNode isn't
  // selected, i.e., doesn't have the caret.
  final hasCaret = componentContext.nodeSelection != null ? componentContext.nodeSelection.isExtent : false;
  if (hasCaret) {
    return null;
  }

  // TODO: create the widget
}

This particular example has a lot of conditions that need to be met before choosing to build a Widget. If any of the conditions fail, and the builder returns null, the editor moves on to the next builder until eventually a Widget is returned.

Once all of the conditions are met, a Widget needs to be built and returned.

Return a new widget

Instantiate and return a new Widget.

The TextWithHintComponent returned in this example is implemented in a later step.

Widget firstParagraphHintComponentBuilder(ComponentContext componentContext) {
  // Existing situation conditionals are omitted for brevity...

  // Create and return a new TextWithHintComponent to render
  // what we want.
  return TextWithHintComponent(
    documentComponentKey: componentContext.componentKey,
    text: paragraphNode.text,
    styleBuilder: componentContext.extensions[textStylesExtensionKey],
    metadata: paragraphNode.metadata,
    hintText: 'Enter your content...',
    textAlign: textAlign,
  );
}

A new TextWithHintComponent is instantiated and returned. This component is rendered like any other widget within the editor.

Define a custom component widget

Typically, a custom ComponentBuilder is defined for the purpose of rendering a new type of Widget within the DocumentLayout.

The following is one possible implementation of TextWithHintComponent, achieving hint text in the first paragraph of an empty document.

class TextWithHintComponent extends StatelessWidget {
  const TextWithHintComponent({
    Key key,
    @required this.documentComponentKey,
    @required this.text,
    @required this.styleBuilder,
    this.metadata = const {},
    @required this.hintText,
    this.textAlign,
  }) : super(key: key);

  final GlobalKey documentComponentKey;
  final AttributedText text;
  final AttributionStyleBuilder styleBuilder;
  final Map<String, dynamic> metadata;
  final String hintText;
  final TextAlign textAlign;

  @override
  Widget build(BuildContext context) {
    // The hint text alignment needs to match the alignment of
    // the content that will appear in this paragraph. Look up
    // the preference from the node's metadata.
    TextAlign textAlign = TextAlign.left;
    final textAlignName = metadata['textAlign'];
    switch (textAlignName) {
      case 'left':
        textAlign = TextAlign.left;
        break;
      case 'center':
        textAlign = TextAlign.center;
        break;
      case 'right':
        textAlign = TextAlign.right;
        break;
      case 'justify':
        textAlign = TextAlign.justify;
        break;
    }

    return MouseRegion(
      // We want a text style cursor to appear when the user hovers
      // over any area within the first line of the paragraph.
      cursor: SystemMouseCursors.text,
      child: Stack(
        children: [
          // Display the hint text.
          Text(
            hintText,
            textAlign: textAlign,
            style: styleBuilder({blockType}).copyWith(
              color: const Color(0xFFC3C1C1),
            ),
          ),
          // Display a standard text component. We know that there
          // isn't any text, but displaying the standard text
          // component gives us the standard height for a line
          // of paragraph text. This avoids a jarring change in height
          // when the first character is entered.
          Positioned.fill(
            child: TextComponent(
              key: documentComponentKey,
              text: blockLevelText,
              textAlign: textAlign,
              textStyleBuilder: styleBuilder,
            ),
          ),
        ],
      ),
    );
  }
}

The most important thing about building a Widget as a document component is correctly handling the documentComponentKey. The editor uses documentComponentKeys to locate the position and size of components within a document.

Typically, the widget returned from a ComponentBuilder should attach itself directly to the documentComponentKey. However, in this example, because an existing component is being wrapped by other widgets, and those widgets don't change the visual size of the component, the documentComponentKey is given to the TextComponent descendant.

Regardless which widget receives the documentComponentKey, that widget must be a StatefulWidget and its State object must implement DocumentComponent. The DocumentComponent API includes all the functionality that every visual component must implement to play nicely within a DocumentLayout with other DocumentComponents.