craft.js icon indicating copy to clipboard operation
craft.js copied to clipboard

Future of Craft.js - taking it to the next level! 🚀

Open prevwong opened this issue 1 year ago • 26 comments

Background

Hey folks! Craft.js is about 3 years old now and in that time we've seen plenty of Craft-based page editor implementations in the wild. I'm incredibly grateful for everyone who has been using Craft, and for the feedback I have gotten all this while.

Having personally worked at some companies that used Craft has helped me to recognise the strengths and as well some of the shortcomings of the framework. One of those shortcomings that I felt was in the state management system itself.

While the current EditorState in Craft works pretty well, it falls short for more complex page editor requirements. Currently, it isn't quite possible to build complete UI components with Craft.

Let's take a look at how a typical UI component built in React looks like:

const posts = [...];

const MyComponent = (props) => {
    const [counter, setCounter] = useState(0);
    return (
        <div>
            <h1>Hello World!</h1>
            {
                props.enabled && <p>I'm enabled!</p>
            }
            {
                counter > 0 && (
                    <h2>I'm non-negative!</h2>
                )
            }
            {
                posts.map(post => <h2>{post.name}</h2>)
            }
            <button
                onClick={() => {
                    setCounter(counter + 1);
                }}>
                Increment
            </button>
        </div>
    )
}

A React component is able to do the following:-

  • Render an HTML output based on the props given
  • Supports JS expressions
  • Hold stateful values
  • Render an element conditionally based on an expression
  • Iterate through an array and render an element multiple times for each item in the array

The current Craft's EditorState is essentially the equivalent of building a single UI component without states and props; and with the ability of adding/reordering JSX templates and mutating simple prop values of those JSX templates. However, as seen in the React example above, much of what is possible with building UI components with libraries like React, isn't really replicable with a page builder built with Craft.

With that in mind, I spent the past couple of months trying to build a new state management system for Craft; one that could allow end-users of your page editors to build UI components that could be as complex as ones that developers could write in React code. Due to the complexity and the breaking changes that this may possibly introduce, this new state management system is currently being worked on as a separate project, called Reka.

Just to avoid confusion, Reka is not a replacement for Craft. It's simply intended to replace the internal state management system in Craft to enable the following benefits.

Benefits

User-designed Components

End-users of your page editor will now be able to design entire components and re-use them anywhere:

https://user-images.githubusercontent.com/16416929/229722236-ef8e21ab-ca6b-4a6c-8b59-509cf8ac754c.mp4

[
  {
    type: 'RekaComponent',
    name: 'Counter',
    props: [
      {
        type: 'ComponentProp',
        name: 'initalValue',
        init: { type: 'Literal', value: 0 },
      },
    ],
    state: [
      {
        type: 'Val',
        name: 'counter',
        init: { type: 'Identifier', name: 'initialValue' },
      },
    ],
    template: {
      type: 'TagTemplate',
      tag: 'p',
      props: {},
      children: [
        {
          type: 'TagTemplate',
          tag: 'text',
          props: { value: { type: 'Literal', value: 'My counter: ' } },
        },
        {
          type: 'TagTemplate',
          tag: 'text',
          props: { value: { type: 'Identifier', value: 'counter' } },
        },
      ],
    },
  },
  {
    type: 'RekaComponent',
    name: 'App',
    state: [],
    template: {
      type: 'TagTemplate',
      tag: 'div',
      props: {},
      children: [{ type: 'TagTemplate', component: 'Counter', props: {} }],
    },
  },
];

// which is the equivalent of the following React code:
const Counter = ({ initialValue = 0 }) => {
  const [counter, setCounter] = useState(initialValue);
  return <p>My Counter: {counter}</p>;
};

const App = () => {
  return (
    <div>
      <Counter initalValue={10} />
    </div>
  );
};

As seen above, we can now allow end-users to build UI components with states, props, expressions and nearly almost all the fundamentals that you would expect from a UI library like React.

In case you're wondering, yes - you can even render the same element from an array (like you can with .map in React)

Realtime Collaboration

Multiplayer is often a requirement for large page editor implementations and to support this, Reka provides an additional extension that enables multiplayer capabilities via Y.js CRDT which supports common protocols including Web Sockets and WebRTC.

https://user-images.githubusercontent.com/16416929/229785515-68553713-4453-4396-b941-9767aa15817e.mov

Extensible State

Another challenge that I personally faced with Craft when building page editors was the ability of storing additional data related to the editor; previously I would store these as part of the custom property of the ROOT node in Craft. With Reka, this is now achievable in a less awkward fashion with the use of Extensions. For example, let's say you want your end-users to be able to leave a comment on a template element; you can store these comments directly as part of the State:

https://user-images.githubusercontent.com/16416929/229728280-372bfed6-5c19-459c-add0-5c1275a4d8e9.mov

import { createExtension } from '@rekajs/core';
type CommentState = {
  comments: Array<{
    templateId: string; // Id of the Template element associated with the comment
    content: string;
  }>;
};
const CommentExtension = createExtension<CommentState>({
  key: 'comments',
  state: {
    // initial state
    comments: [],
  },
  init: (extension) => {
    // do whatever your extension may have to do here
    // ie: send some data to the backend or listen to some changes made in State
  },
});

// Usage
reka.change(() => {
  reka.getExtension(CommentExtension).state.comments.push({
    templateId: '...',
    content: 'This button tag should be larger!!',
  });
});

Additional Functionalities

With the current Craft state system, you are already able to expose React components via the resolver prop so your end-users could use them. With Reka, you can continue to expose these React components but you're also able to expose other stateful values and JS functions that your end-users can interact with:

https://user-images.githubusercontent.com/16416929/229712736-4cd0d053-07e8-4f8e-b540-1ee45b5ad7a3.mp4

import * as React from 'react';
import confetti from 'canvas-confetti';

const Icon = (props) => {
    return <SomeIconLibrary icon={props.icon} />    
}

const reka = Reka.create({
   externals: {
       components: [
           t.externalComponent({
               name: "MyReactIcon",
               render: (props) => <Icon {...props} />
           })
       ],
       functions: () => ({
           doConfetti: () => {
               confetti();
           }
       }),
       states: {
           scrollTop: 0
       }
   }
});

Disadvantages

A much larger and complex data structure

The current Craft EditorState is a simple implicit tree data structure, whereas Reka is an AST. As such, a Reka AST for an equivalent EditorState is expected to be larger:

// EditorState
{
    id: "ROOT",
    data: {
      type: "div",
      props: {
          text: "Hello"
      }    
    }
}

// Reka AST
{
    id: "some-random-id",
    type: "RekaComponent",
    state: [],
    props: [],
    template: {
        type: "TagTemplate",
        tag: "div",
        props: {
            text: {
                type: "Literal",
                value: "Hello"
            }
        }
    }
}

In particular, you can see how the "props" of each element is now more verbose in the AST, since now it can be any expressions (ie: pointing to some variable, or concatenating values) and not just literals.

What's next?

Reka is a massive departure from Craft's current state system, hence I started this thread to get everyone's thoughts/concerns on it. Additionally, I've written up documentation along with examples for Reka so everyone could try it out for themselves.

Then, I would like to integrate Reka into Craft - which I will admit is easier said than done as this would be somewhat of a rewrite of Craft:-

  • Integrating Craft's events and drag-n-drop system with Reka
    • Much of the logic in existing event system to handle drag-n-drop can be reused
    • We may need to introduce a way to handle drag-n-drop rules (ie: canDrag, canDrop etc) for the user-designed components in Reka.
  • Introducing higher-level methods to interact with Reka
    • We already have these now in Craft, in the form of actions and query. Perhaps, adding these to work with Reka would make it less necessary for consumers to interact with the AST directly.
  • Adding undo/redo
  • Linked Nodes(?)
    • Currently this is not supported within Reka and we need to evaluate if we should continue to support it (or if there's a better way to implement it); considering that it is often difficult to maintain and causes confusion with developers.
  • Backwards compatibility(?)
    • Ideally, it would be great to have all page editors built with the current Craft EditorState to just work with new Craft with the integrated Reka AST.
    • This may involve providing some drop-in legacy adapter that enables the existing EditorState to be transformed into the AST.

Putting it all together

https://user-images.githubusercontent.com/16416929/229729268-7e2e3170-d48a-435d-b72b-dffa841e6e53.mov


That's it for now, please feel free to let me know your thoughts below!

If you or your organisation is using Craft.js and would like to support me for my efforts - please consider sponsoring! ❤️

prevwong avatar Apr 04 '23 12:04 prevwong