Keystone: Rich text editor

Created on 14 Jan 2019  路  19Comments  路  Source: keystonejs/keystone

This is a consolidation of issues #10/#316/#377/#593 and new work by @mitchellhamilton.

Modern CMSs have _block_-based editing experiences which provide structure to input data beyond just HTML or markdown, but which also allow for freedom in the way those blocks are pieced together.

Keystone 5 needs a block-based editing experience.

Note: For motivations, see this comment below

There are internal needs that will be met by this style of editor over, say, a markdown or html field type.

@mitchellhamilton has been making great progress on such an editor and will provide more details below 馃憞

See comments below for:

  • How to handle KS5 native types as Blocks (similar to bold/link/etc)
  • How to model Relationships
  • How to store the data produced by the editor
  • How to parse & render that data

TODO in no particular order

  • [x] GraphQL Types

    • [x] Generate internal join tables based on the block types added

    • [x] Generate input types for mutations

    • [x] Blocks generate GraphQL input types to be added as child fields on the Content field type

      For example;

      graphql { document relationshipUsersMention: relationshipUser(where: { type: 'mention' }) { username } relationshipUsersEmbed: relationshipUser(where: { type: 'embed' }) { username avatar bio } }

  • [x] Mutations

    • [x] Client: Serialise Slate's document data before sending to the server (see https://github.com/keystonejs/keystone-5/issues/616#issuecomment-458831325)

    • [x] Server: Parse the serialised data on the server to perform relationship creates/connects

    • [x] Server: Inject resolved create/connect ids into serialised document before saving to DB

  • [ ] Queries

    • [x] Client: Specify shape of data to return for complex blocks with a graphQL query

    • [x] Server: Parse serialised document to extract related IDs

    • [x] Server: Perform queries for the given IDs

    • [x] Client: Allow client to mutate data before injection

      For example;

      javascript query(gql` { document relationshipUsersMention: relationshipUser(where: { type: 'mention' }) { username } relationshipUsersEmbed: relationshipUser(where: { type: 'embed' }) { username avatar bio } } `).then(data => { return { ...data, relationshipUsers: data.relationshipUsersMention.concat(data.relationshipUsersEmbed), } });

      Which gets it back into the format the deserialisation lib is expecting.

    • [x] Client: Inject resolved data into serialised document

    • [ ] Switch to graphql-type-json for the slate document (#685)

  • [x] CloudinaryImage block type

    • [x] Move fields/types/Content/views/editor/blocks/image-container.js to fields/types/CloudinaryImage/views/block.js

    • [x] Add support for rendering { data: { publicUrl: '...' } }

  • [ ] Relationship block type

    • [ ] ...

  • [ ] Tests
  • [x] Documentation
small 1 admin-ui design required docs graphql

Most helpful comment

@jesstelford can we get a status update on the blocks\content editor?

This issue has become really long and hard to track. It looks like the outstanding issues are related to blocks only? I'd like to review this issue and close it.

If there is any outstanding work on the content field type I suggest we consolidate this into a single issue and close these as duplicates: #650, #685, #728. There are also some minor UI inconsistencies that need attention and it would be good to see this captured if we're making a new issue.

If you don't have any time for this soon book in a short discussion with me and I'll see what I can do. I just need your context and history.

All 19 comments

This is what I'm thinking the api for the rendering a read only version of the content should look like

// @flow
import * as React from 'react';

// any other things renderers should accept?
type RendererComponentProps = {
  // data contains things like the src value of an image and etc.
  data: Object,
  children: React.Node,
};

type Components = {
  [type: string]: (props: RendererComponentProps) => React.Node,
};

type ContentValue = any;

type Props = {
  value: ContentValue,
  // similar to react-select and etc. we'll define a
  // default value for the components which can render
  // all of the built in types but you can change
  // how built in types are rendered or set how a custom block type
  // should be rendered

  blocks: Components,
};

let Content = (props: Props) => {
  // magic....
};

export let components = {
  paragraph: ({ children }) => <p>{children}</p>,
  image: props => <img src={props.data.src} />,
  link: ({ children, data }) => <a href={data.href}>{children}</a>,
};

<Content value={fromGraphql} components={components} />;

// how can we enforce that all block types are defined?
// a meta field in graphql that returns all the block types?

// (question for later) how should we handle passing related data in?

Code notes from our meeting today:

/*
 *
 * NOTES:
 * - Needs Array types
 * - Or maybe not; create an internal list per block type with unique
 *   (deterministic) names
 *
 */
// type/Content.js
let internalList;

if (!internalList) {
  internalList = internal.DONOTUSERORYOULLBEFIRED.createList({
    fields: {
      // The meta data structure that is parsed and used by the client. Eg; the output from Slate
      structure: { type: JSON /* or String */ },
      userBlocks: { type: Array, config: { type: Relationship, ref: 'User' } },
      userBlocks: { type: Relationship, many: true, ref: 'Content_abc123_User' },
      postBlocks: { type: Array, config: { type: Relationship, ref: 'Post' } },
      tagBlocks: { type: Array, config: { type: Relationship, ref: 'Tag' } },
      imageBlocks: { type: Array, config: {
        // comes from the config given to `createList{ ... Content: { }}`
        type: CloudinaryImage, transformations: { fullWidth: 800 } }
      },
    }
  });
}

// index.js
keystone.createList('Post', {
  fields: {
    content: { type: Content, blocks: [
      [Content.blocks.cloudinaryImage, { fullWidth: 2000 }]
    ]},
    author: { type: Relationship, ref: 'User' }
  }
});

keystone.createList('Docs', {
  fields: {
    content: { type: Content },
  }
});


// client.js
// fetching
gql`
  query getPosts() {
    allPosts {
      title
      content(cloudinaryImage: { fullWidth: { width: 800 } })
    }
  }
`;

// saving
query(gql`
  mutation saveImage($image: CloudinaryImage) {
    createImage(data: $image) {
      id
    }
  }
`).then(({ id }) => {
  // insert id into content
  gql`
    mutation savePost($content: String!) {
      createPost(data: {
        content: $content
      }) {
        # ...
      }
    }
  `;
});

I'm going to work on this now.

See also the List type discussion: https://github.com/keystonejs/keystone-5/issues/195

with "Block Based", did you mean something like new WordPress editor https://wordpress.org/gutenberg/

Yeah, close to that. It wont be as feature rich (to start) - we'll just be exposing some basic formatting + relationships to other types for now. But ideally we'll build it in such a way it can be used to generically describe any kind of "thing" that exists within the content 馃憤

Getting into the details a bit more today on how to model the data, and I think I've landed on a nice database layout which allows us to leverage existing query resolution logic and relationships, etc:

Given a list config like so:

keystone.createList('User', {
  fields: {
    name: { type: Text },
    email: { type: Text }
  }
});

keystone.createList('Post', {
  fields: {
    post: {
      type: Content,
      blocks: [
        Content.blocks.cloudinaryImage,
        [Content.blocks.relationship, { ref: 'User' }],
        [Content.blocks.relationship, { ref: 'Post' }],
      ],
    },
  },
});

We'd end up with the following DB tables:

| User | _notes_ |
|-----------------|---------|
| id: ID | |
| name: String | |
| email: String | |

Postnotes
id: ID
post: String(JSON)When serialised for storage in the database, would reference an ID on the below join table:

{
  type: 'relationship.User',
  data: {
    _joinId: 'abc123' // NOTE: A _ContentType_relationship_User id
  }
}


When deserialised / hydrated for use in the editor, would look like:

{
  type: 'relationship.User',
  data: {
    _joinId: 'abc123', // NOTE: A _ContentType_relationship_User id
    id: 'def456', // NOTE: A User id
    name: 'Sam',
    email: '[email protected]',
  }
}


Open Question: How do we specify the shape of the data (ie, the id/name/email fields)? A graphQL query would be ideal

There needs to be a join table per relationship type;

| _ContentType_relationship_User | _notes_ |
|-----------------|---------|
| id: ID | |
| to: ID | The User ID |
| fromRef: String | Who is this data for? eg; Post.post. Useful for cleaning stale data / reverse lookups / etc. |
| from: ID | The ID of the item in the fromRef list |

| _ContentType_relationship_Post | _notes_ |
|-----------------|---------|
| id: ID | |
| to: ID | The Post ID |
| fromRef: String | Who is this data for? eg; Post.post. Useful for cleaning stale data / reverse lookups / etc. |
| from: ID | The ID of the item in the fromRef list |

And a table of data for every other non-relationship type.

| _ContentType_cloudinaryImage | _notes_ |
|-----------------|---------|
| id: ID | |
| <...CloudinaryImage> | ie; the fields normally generated by { type: CloudinaryImage } |

We can then do some cool things like resolving the join table relationships serverside as long as the relevant data is passed along.

Architectural Flow

The flow would go something like:

  1. User makes some edits in the editor
  2. On save

    1. [client] Editor value is serialized

    2. [client] Custom GraphQL mutation is generated & sent to server

    3. [server] Create/resolve any joins/relationships

    4. [server] Inject new _joinIds into serialized content

    5. [server] Save the serialized content value to DB

  3. On load

    1. [client] Send query for content + joins/relationships

    2. [server] Read serialized content from DB

    3. [server] Extract _joinIds from serialized content

    4. [server] Resolve joins/relationships

    5. [server] Return serialized content + resolved joins/relationships

    6. [client] Deserializes content, rehydrating _joinIds with actual values

    7. [client] Initialise the editor with deserialized value

  4. Repeat

Example

Given this (pseudo) example content that comes out of the editor client side:

const slateData = {
  document: {
    nodes: [
      {
        type: 'cloudinaryImage',
        data: {
          _joinId: 'abc123', // Previously saved in the join table
          src: 'http://...',
        }
      },
      {
        type: 'cloudinaryImage',
        data: {
          // No _joinId, so must be a new item not yet saved
          src: 'data:image/png;...',
          file: { /* ... DOM File ... */ },
        }
      },
      {
        type: 'relationship.User',
        data: {
          _joinId: 'xyz890', // Previously saved in the join table
          id: 'abc123',
          username: 'jesstelford',
        }
      },
      {
        type: 'relationship.User',
        data: {
          // No _joinId, so must be a new item not yet saved
          id: 'def345',
          username: 'sammy',
        }
      }
    ]
  }
}

We can run a client-side serialisation (_step 2.1_) over this to gather up all the relevant data the server needs to process like so:

const serialisedData = {
  value: {
    document: {
      nodes: [
        {
          type: 'cloudinaryImage',
          data: {
            index: 0,
          }
        },
        {
          type: 'cloudinaryImage',
          data: {
            index: 1,
          }
        },
        {
          type: 'relationship.User',
          data: {
            index: 0,
          }
        },
        {
          type: 'relationship.User',
          data: {
            index: 1,
          }
        }
      ]
    }
  },
  meta: {
    cloudinaryImage: [
      { connect: { id: 'abc123' } },
      { create: { image: /* DOM File upload */ } },
    ],
    relationship: {
      User: [
        { connect: { id: 'xyz890' } }, // ie; connecting to the join table entry which already exists
        { create: { ref: { connect: { id: 'def345' } } } }, // ie; creating an entry in the join table that is connected to the given user
      ]
    }
  }
}

Which further allows us to specify a graphQL mutation (_step 2.2_) like so:

mutation updateContent(
  $postId: ID!,
  $post: String!,
  $cloudinaryImages: _ContentType_cloudinaryImageRelateToManyInput
  $userRelationships: _ContentType_relationship_UserRelateToManyInput
  $postRelationships: _ContentType_relationship_PostRelateToManyInput
) {
  updatePost(
    where: { id: $postId },
    data: {
      post: {
        structure: $post,
        cloudinaryImages: $cloudinaryImages,
        relationships_User: $userRelationships,
        relationships_Post: $postRelationships,
      }
    }
  ) {
    # ...
  }
}
{
  variables: {
    postId: '...',
    post: serialisedData.value,
    ...(serialisedData.cloudinaryImage ? {
      cloudinaryImages: serialisedData.cloudinaryImage
    } : {}),
    ...(serialisedData.relationship && serialisedData.relationship.User ? {
      userRelationships: serialisedData.relationship.User
    } : {}),
    ...(serialisedData.relationship && serialisedData.relationship.Post ? {
      postRelationships: serialisedData.relationship.Post
    } : {}),
  }
}

Then, on the server side, we use the Field#Implemenation#resolveInput to perform the necessary nested relationship operations based on the _ContentType_cloudinaryImageRelateToManyInput & _ContentType_relationshipRelateToManyInput, etc, inputs (_step 2.3_).

Once all the related items are created and/or the join tables have been updated, then the serialised data would be converted into something like the following before finally being returned from the resolveInput() method (_step 2.4_):

{
  document: {
    nodes: [
      {
        type: 'cloudinaryImage',
        data: {
          _joinId: 'abc123', // A _ContentType_cloudinaryImage table ID
        }
      },
      {
        type: 'cloudinaryImage',
        data: {
          _joinId: 'yui654', // A _ContentType_cloudinaryImage table ID
        }
      },
      {
        type: 'relationship.User',
        data: {
          _joinId: 'xyz890', // A _ContentType_relationship_User table ID
        }
      },
      {
        type: 'relationship.User',
        data: {
          _joinId: 'wer098', // A _ContentType_relationship_User table ID
        }
      }
    ]
  }
}

Then when we read the data from the DB, before returning within the graphql request, we'd have to rehydrate it (_steps 3.2-3.5_), something like:

const structure = await readFromDB();

structure.document.nodes = Promise.all(structure.document.nodes.map(node => {
  switch (node.type) {
    case 'cloudinaryImage': {
      const { data: { _ContentType_cloudinaryImage } } = await query(
        gql`
          query getCloudinaryImage($id: ID!) {
            _ContentType_cloudinaryImage(where: { id: $id }) {
              id
              # NOTE: The client defines the shape of this query
              publicUrl
            }
          }
        `,
        { variables: { id: node.data._joinId } },
      );

      return {
        ...node,
        data: {
          _joinId: node.data._joinId,
          ..._ContentType_cloudinaryImage,
        }
      };
    }

    case 'relationship.User': {
      const { data: { _ContentType_relationship_User } } = await query(
        gql`
          query getUser($id: ID!) {
            _ContentType_relationship_User(where: { id: $id }) {
              id
              # NOTE: The client defines the shape of this query
              ref {
                id
                username
                email
              }
            }
          }
        `,
        { variables: { id: node.data._joinId } },
      );

      return {
        ...node,
        data: {
          _joinId: node.data._joinId,
          ..._ContentType_relationship_User.ref,
        }
      };
    }

    default: {
      return node;
    }
  }
}));

_NOTE: This assumes we aren't clobbering any other fields / tables! We should enforce a rule that field & list names cannot begin with _, allowing us to reserve them for internal use._

The query to _get_ the data from the server (_step 3.1) would look like:

{
  document
  relationshipUsersMention: relationshipUser(where: { type: 'mention' }) {
    username
  }
  relationshipUsersEmbed: relationshipUser(where: { type: 'embed' }) {
    username
    avatar
    bio
  }
  cloudinaryImages(where: { display_not: 'full_width' }) {
    # Inline images
    publicUrl: publicUrlFormatted(transformation: { width: '600' })
  }
  cloudinaryImagesFullwidth: cloudinaryImages(where: { display: 'full_width' }) {
    # Hero images
    publicUrl: publicUrlFormatted(transformation: { width: '1000' })
  }
}

In this fashion, the client can specify with high fidelity how they want data returned.

The deserialiser still expects only the keys relationshipUser/cloudinaryImages, so there must be a hook for the developer to mutate back into that format:

const content = queryContent(
  `
{
  document
  relationshipUsersMention: relationshipUser(where: { type: 'mention' }) {
    username
  }
  relationshipUsersEmbed: relationshipUser(where: { type: 'embed' }) {
    username
    avatar
    bio
  }
}
  `,
  data => {
    return {
      ...data,
      relationshipUsers: (data.relationshipUsersMention ? data.relationshipUsersMention : []).concat(data.relationshipUsersEmbed ? data.relationshipUsersEmbed : []),
    };
  }
);
// content is now the fully deserialised JSON string with values correctly inlined.

Hi 馃憢CKEditor 5 project lead here.

I've been recently researching how structured content editing could be implemented in CKEditor 5 for a talk I was preparing for NeosCon and I found this ticket.

The PoC that I built on top of CKEditor 5 looks really promising and it solves the biggest UX problems that Neos CMS has with their current approach (multiple small editor instances for the editable parts of the content).

I made a demo of that prototype (together with a quick intro to CKEditor 5 itself) on NeosCon: https://twitter.com/reinmarpl/status/1133735637578854401.

The demo was tailored for Neos's reality. However, in order to succeed such a project needs to stay generic so it can be used by many CMSes.

Therefore, I'm curious about your requirements regarding structured content editing and potential issues if you already tried some solutions (not sure from the ticket what's the progress).

Thanks in advance for all the feedback! 馃檪

Hey @Reinmar, thanks for sharing your work here!

The talk was very interesting; particularly the proof of concept you showed migrating from Neos existing editor made up of many different parts into a single unified block based editor - this is the exact use case that we're solving for in Keystone 5 too :)

We've been building on top of Slate.js to date, and will complete that work for this editor, but the great thing about Keystone 5 Field Types is it's up to the application developer to pick which one works for them, and I'd love to be able to offer a CKEditor Field Type (we already have a TinyMCE field, and plan to add a Markdown one also).

In building out this experience, the trickiest part I've encountered is serialisation of the editor's data when saving to the DB. Probably best illustrated with a psuedo-example:

Imagine the editor gives us some data structure like so:

{
  blocks: [
    {
      type: 'paragraph',
      blocks: [
        {
          type: 'image',
          data: { url: 'https://placekitten.com/g/2048/600' }
        }
      ]
    }
  ]
}

We can store that in the database easily enough, and enable querying it from our GraphQL API with something like:

query {
  Post(id: "...") {
    content {
      document
    }
  }
}

Where content.document is the JSON structure from above which can be fed back into the editor for display.

Now imagine trying to display/edit that same content on a mobile device; the image is much too large for a small screen, and would use up unnecessary bandwidth / time downloading. If we know ahead of time that the editor will be used on mobile, we could parse the document when we save it to the database and make multiple versions; one per device size. But knowing ahead of time is often impossible in rapidly growing/changing apps, not to mention the data consistency issues multiple versions brings with it.

Instead, we can store just a single copy of the data and allow the client to craft a graphQL query which modifies it at read-time:

query {
  Post(id: "...") {
    content {
      document
      images(transformation: { width: "700", height: "200" })
    }
  }
}

The data is now returned in a normalised format such as:

{
  document: {
    blocks: [
      {
        type: 'paragraph',
        blocks: [
          {
            type: 'image',
            // The data is normalised
            data: { _joinId: 'abc213' }
          }
        ]
      }
    ]
  },
  images: [
    // NOTE: The url matches the requested image size
    { id: 'abc123', url: 'https://placekitten.com/g/700/200' }
  ]
}

Then after requesting the data, we can re-constitute it back into the format the editor expects by denormalising based on the _joinId & type fields for each block:

{
  blocks: [
    {
      type: 'paragraph',
      blocks: [
        {
          type: 'image',
          data: { url: 'https://placekitten.com/g/700/200' }
        }
      ]
    }
  ]
}

There are other possibilities this enables, such as a query to build a gallery of all images in a post, displayed independently of the editor.

query {
  Post(id: "...") {
    content {
      # NOTE: we don't query for `document`, only the images, and only at thumbnail size
      images(transformation: { width: "90", height: "90" })
    }
  }
}

Or for more complex examples:

# Get all the users mentioned in a Post
query {
  Post(id: "...") {
    content {
      userMentions {
        id
        username
        avatar
      }
    }
  }
}
# Make left-aligned images one size, and right-aligned images another
query {
  Post(id: "...") {
    content {
      document
      imagesRightAligned: images(
        where: { align: 'right' }
        transformation: { width: "300", height: "90" }
      )
      imagesLeftAligned: images(
        where: { align: 'left' }
        transformation: { width: "100", height: "200" }
      )
    }
  }
}
# Do a lookup to find all Posts a User 'def345' is mentioned in
# And extract out only the headline block (if set)
query {
  allPosts(where: {
    content: {
      userMentions_contains: {
        id: "def345"
      }
    }
  }) {
    id
    content {
      headline {
        text
      }
    }
  }
}

I wonder if this is something you've thought about for the CKEditor? I know it's far beyond the editor's responsibility, but certain decisions when creating the editor's data structure can impact how easy it is to build these kinds of features.

It looks like you haven't had a response in over 3 months. Sorry about that! We've flagged this issue for special attention. It wil be manually reviewed by maintainers, not automatically closed. If you have any additional information please leave us a comment. It really helps! Thank you for you contributions. :)

I found https://editorjs.io/ with 9.6k stars.
@jesstelford have you considered this before, if any drawbacks, let me know. I am planning to create this as field type for contrib (if not core)

https://github.com/stfy/react-editor.js is React wrapper for this

@jesstelford can we get a status update on the blocks\content editor?

This issue has become really long and hard to track. It looks like the outstanding issues are related to blocks only? I'd like to review this issue and close it.

If there is any outstanding work on the content field type I suggest we consolidate this into a single issue and close these as duplicates: #650, #685, #728. There are also some minor UI inconsistencies that need attention and it would be good to see this captured if we're making a new issue.

If you don't have any time for this soon book in a short discussion with me and I'll see what I can do. I just need your context and history.

It looks like there hasn't been any activity here in over 6 months. Sorry about that! We've flagged this issue for special attention. It wil be manually reviewed by maintainers, not automatically closed. If you have any additional information please leave us a comment. It really helps! Thank you for you contribution. :)

Would love to be able to add images and store them as files, instead of data:images in the database. Something like LocalImage block

Almost every item here has been complete. This grab-bag issue is probably no longer service purpose and future issues can be raised for additional WYSIWYG features

Was this page helpful?
0 / 5 - 0 ratings

Related issues

jossmac picture jossmac  路  10Comments

JedWatson picture JedWatson  路  17Comments

molomby picture molomby  路  11Comments

bothwellw picture bothwellw  路  18Comments

cowjen01 picture cowjen01  路  13Comments