question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Decoupling plugin and nodes

See original GitHub issue

Problem

Plugins and nodes are tightly coupled and it makes it harder to extend default nodes and maintaining existing plugins functionality.

Example

Let’s take markdown plugin (copy/paste logic is also a good example), it exports bunch of node creation helpers ($createHeadingNode, $createCodeBlockNode, $createListNode, etc).

Then if I want to extend HeadingNode behaviour, for example to add id attribute to each heading so it can be used as an anchor (smth like <h1 id="getting-started-with-react">Getting started with React</h1>, see our README doing it). To do so I’d extend default node and add new behaviour on top:

class AnchorHeadingNode extends HeadingNode {
  static getType() {
    return 'heading';
  }

  createDOM<EditorContext>(config: EditorConfig<EditorContext>): HTMLElement {
    const element = super.createDOM(config);
    element.setAttribute('id', getAnchorID(this));
    return element;
  }

  updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean {
    dom.setAttribute('id', getAnchorID(this));
    return false;
  }
}

But now the problem is that all plugins that might insert HeadingNode (markdown, toolbar, copy/paste), they all keep using default HeadingNode, because they all use $createHeadingNode imported from default heading node file.

Potential solution:

Replace $createHeadingNode() implementation from

function $createHeadingNode(tag): HeadingNode {
  return new HeadingNode(tag);
}

to

function $createHeadingNode(tag): HeadingNode {
  const NodeConstructor = $getNodeFromRegistry(HeadingNode);
  return new NodeConstructor(tag);
}

...
// and smth like:
function $getNodeFromRegistry<T>(nodeKlass: T): T {
  return $getActiveEditor()._registeredNodes.get(nodeKlass.getType());
}

This will allow passing AnchorHeadingNode into LexicalComposer, and since it has the same type (‘heading’), it’ll be used whenever $createHeadingNode is used, so all callsites that create heading will use our extended heading node.

Issue Analytics

  • State:closed
  • Created 2 years ago
  • Reactions:11
  • Comments:31 (24 by maintainers)

github_iconTop GitHub Comments

5reactions
EgonBoltoncommented, Jul 21, 2022

I’ve been thinking about this a lot, and the conclusion I’ve come to is that the different ElementNode types shouldn’t be different classes, but should be handled with the __type property.

Before I get to the reasons, I want to talk about shared properties. I refer to them as the properties that different element nodes share in common and affect the appearance of the node. In Lexical currently this would be __indent. This property has presented some challenges that have been resolved in a somewhat strange way in my opinion. However, while __indent is enough of a property to make an argument for what I’m going to say next, I want to note that this isn’t the only shared property that someone might want to incorporate into an editor. Notice for example how applications like Notion, Dynalist, Remnote, Roam Research, and Workflowy, have properties like __color (at element level), __collapse, __hide, __shared, __backlinks, etc. The possibilities are many.

So, some reasons why the different types of ElementNode should not be different classes, but should be handled with the __type property:

  1. When converting one elementNode to another, the shared properties are lost (for example, convert an indented paragraph to a heading and the indentation will be lost). The reason for this is that since they are different classes, a new instance is required. Of course, by changing the type of a node one could copy the desired properties to the new node, but this would be terrible and I don’t see any benefit to it.

  2. Even if some properties are used only by some nodes, it would be ideal if the other nodes keep that information. The reason for this is that users often undo operations using the reverse operation instead of ctrl + z. For example, ListItem currently ignores the __indent property because it handles it with nesting. But if I have indented paragraphs, I would like the indents to be preserved when I convert them to a list and return them to paragraphs. Or if I have a checklist checked, convert it to something else, and return to the checklist, the “completed” property should be preserved. I recommend seeing this notion blog article where they talk about this.

  3. If the different types of elementNodes are subclassed and we have a shared property, then the same display logic must be defined in the updateDOM and createDOM of each node, which makes it very difficult to maintain the code. In Lexical right now there is a workaround with the __indent case that is handled in Reconcilier.ts. Besides being a bit confusing, the rest of us can’t do the same for other shared properties we might want to add.

  4. Lexical is actually already doing what I’m proposing (in part). I mean HeadingNode. If H1-H6 are handled in the same class, I don’t see any reason not to use paragraphs also in the same class, for example.

  5. When converting one type of elementNode to another, the key changes. If someone wanted to implement links to the paragraph, this would result in very brittle links. All of the apps I mentioned above and others like Onenote allow element-level links.

  6. This is perhaps more personal, but I think that a functional API instead of a class-based API like the one I propose below would offer a much friendlier development experience. Even element nodes could be defined as React components (as slate does for example).

My proposal then is for an api that allows registering

  • new elementNodes properties (__indent, __color, etc.)
  • new values ​​for the __type property, with their respective rendering displays.

EDIT: I could help with this.

EDIT 2:

What I write below I do not consider as essential or necessary as the above. I thought to open it in a new thread but it’s quite related.

Looking a bit at the SlateJS API I mentioned in point 6 (do we agree that it’s beautiful?), I think another improvement that could be made to ElementNode is that instead of forcing that 1 ElementNode = 1 HTML tag node, the following more flexible conditions could be imposed:

  1. An ElementNode should always have a top-level element.
  2. An ElementNode should not have text nodes (obviously I mean the DOM inherent to the node, not its child nodes).

The first condition would be to make deserialization much easier. The second condition would be to make selection much easier. Right now, if someone wants to implement a node that isn’t 1-to-1 related to an HTML tag, their only option is to make a decoratorNode (which isn’t always desirable), or to create a bunch of nodes in a very complex way ( as happened to @gutenye here (see the codesandbox) and is happening to me in other use cases).

About DecoratorNode and TextNode All my comment so far is about ElementNode, because that is where the improvements that this change would bring are most evident. However, TextNode and DecoratorNode are also designed by class extension, and could well be changed to a master class that could have methods extended. At the moment, I can see that making the change with them could unify the entire library in a coherent architecture and a more friendly API (everything that needs to be defined to give a certain style to a text, for example, seems a bit excessive to me). However, I still haven’t gotten around to looking in depth at what other pros and cons would be to making the same change on these 2 nodes. I’m still investigating, I’ll keep this thread updated with my conclusions.

4reactions
fantactukacommented, Feb 11, 2022

That’s fair for headings, but the point remains: there’s no way to extend default nodes behaviour and keep all plugins working with extended nodes instead of default ones. We can make similar case with links or mentions, when for certain surfaces we’ll need to add data attribute on their dom nodes to support hover cards. Or if we want to add title="<current highlight language>" to code block. The only way to make it possible now is monkey patching default nodes:

import {LinkNode} from 'lexical/LinkNode';
const createDOM = LinkNode.prototype.createDOM;
LinkNode.prototype.createDOM = function() {
  const dom = createDOM.apply(this, arguments);
  dom.setAttribute('data-hovercard', this.getURL());
  return dom;
}

Then whenever we insert link it’ll have required data attribute, but it does not look pleasing

Read more comments on GitHub >

github_iconTop Results From Across the Web

A Look at Why Some Frontend Developers are Decoupling ...
Decoupling WordPress is a more customized, complex build that could mean more time and money up front, although cost-effective in the long run....
Read more >
What is a Decoupled CMS? | Content Management Systems
A decoupled CMS allows changes to be made to the presentation (formatting) and behavior (programming) layer without having an effect on the content...
Read more >
The Rise of WordPress Frontend Options, Themes vs. REST ...
Decoupling, or separating the backend from the frontend with a framework like Strattic, also delivers a level of complexity that should be ...
Read more >
Decoupled WordPress for the Enterprise
What is a decoupled CMS? ... Traditionally, a full-featured Content Management System (CMS) like WordPress directly renders the user experience of your site...
Read more >
The Ups And Downs Of Creating A Decoupled WordPress
Let's create a small test plugin. Create a test-documentation folder in the wp-content/plugins folder, and add documentation.php file that ...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found