puck icon indicating copy to clipboard operation
puck copied to clipboard

Add rich-text support with WYSIWYG

Open xaviemirmon opened this issue 2 years ago • 21 comments

It would be amazing to have support for minimal rich text formatting on a text area field.

What about something like Slate?

I would need it as HTML and Markdown for different contexts.

xaviemirmon avatar Oct 11 '23 13:10 xaviemirmon

Definitely on our radar! Thanks for opening a ticket - I've been meaning to.

Implementation wise I imagine we might use something like Slate, but not entirely sure.

One thing I'm not sure of is whether a WYSIWYG RTE is the same field type as a Markdown editor.

chrisvxd avatar Oct 11 '23 14:10 chrisvxd

I am planning on adding this feature as a custom prop. My plan is to use Blocksuite, which is WYSIWYG with MD export as well. :)

BleedingDev avatar Oct 22 '23 11:10 BleedingDev

@chrisvxd I've been using Lexical within a custom field and like the approach so far. Lexical has less out of the box, but is one of the most extensible/customizable libs for building a rich text editor. Could be worth looking into. The lexical plugin model would allow some very cool integration with the puck editor at large, like dropping content directly into the rich text field to be rendered along with text content.

kadencartwright avatar Oct 31 '23 21:10 kadencartwright

How does Lexical compare to Tiptap? @kadencartwright

BleedingDev avatar Oct 31 '23 21:10 BleedingDev

How does Lexical compare to Tiptap? @kadencartwright

From my research they cover a similar use case, as both are more about providing the tools to build a rich text editor rather than giving you something out of the box.

I haven’t used tip tap personally but was turned off by the freemium model as it generally makes projects significantly more susceptible to enshittification in my experience

kadencartwright avatar Oct 31 '23 22:10 kadencartwright

The thing is that right now we have multiple options and I think that it either needs proper research (to include it in the Puck core) or we should implement it as a plugin.

What I see are 3 main competitors:

  • Tiptap
  • Lexical
  • Blocksuite

BleedingDev avatar Nov 01 '23 11:11 BleedingDev

If anyone is interested, it's possible to implement this using a custom field type. I'd be curious to see how the different options work.

chrisvxd avatar Nov 01 '23 18:11 chrisvxd

When I raised this issue, I thought Puck would have a single supported RTE that would be provided as a part of the out-of-box experience. This could (and probably should) live outside the core package but be available for the demo and next-recipe packages. For a project I'm working on, we need CKEditor, so I'm working on this as a custom field. I think CKEditor is far too hefty for many generic cases, but I'll share it an example when it's working.

xaviemirmon avatar Nov 08 '23 13:11 xaviemirmon

@chrisvxd You wouldn't, by any chance, have an example of this in a public repo somewhere? Would love to see it in action. I'm wondering if the sidebar offers enough space for this - or whether it would mean having to custom-implement the interface to make space for the editor.

@xaviemirmon It's been a while - so just wanted to check if you ever implemented this and could link it?

JD-Robbs avatar Nov 04 '24 02:11 JD-Robbs

@JD-Robbs I do not have a public RTE field, but there is an inline RTE component listed in awesome-puck (see related #150)

chrisvxd avatar Nov 15 '24 10:11 chrisvxd

puck-rich-text not working with the latest version of Puck

I'll update in the next few days. If you've got any questions about implementing your own, feel free to hit me up.

4leite avatar Nov 20 '24 07:11 4leite

puck-rich-text not working with the latest version of Puck

I'll update in the next few days. If you've got any questions about implementing your own, feel free to hit me up.

Thanks for your quick reply. I used the response in this thread as a base and built my own rich-text-editor using tiptap.

Screenshot 2024-11-21 at 12 02 59

Rich text menu with editor is on the right sidebar and I only render the content in the middle viewport just like the example Text block. If we could improve the library in a way that it provides WYSIWYG built-in, it would be amazing.

Just a feedback: I think that the number of examples should be increased. For each platform (remix, react, vue etc.) there should be a subfolder that provides one command setup.

bakikucukcakiroglu avatar Nov 21 '24 09:11 bakikucukcakiroglu

Any updates on the rich-text support?

GnussonNet avatar Aug 11 '25 15:08 GnussonNet

Hey @GnussonNet!

We are currently planning to release inline text editing in version 0.20. This update will also introduce a registerOverlayPortal function, which allows you to remove the drag overlay from specific elements in your components so they remain interactive in the canvas. This would make it easy to implement your own version with tools like TipTap.

For now, you can create your own rich text editing field as a custom field. The only limitation with this approach is that you would need to handle rich text editing directly in the sidebar, not in the canvas itself.

FedericoBonel avatar Aug 12 '25 02:08 FedericoBonel

For this issue, we're going to need to add a field config which allows for extension/modification of the existing editor config. There are two different instances of the menus (Inline and Field panel) which benefit from having separate configurations. There are two separate usecases that I feel the RTE should solve.

  1. Adding or removing included buttons for menu and inlineMenu
  2. Adding extensions and extra buttons for TipTap.

I also have grouping to allow to users to group actions.

Current implementation

My current implementation makes the overriding of the default config possible but is too cumbersome:

const myConfig = {
  menu: {
    ...defaultRichTextConfig.menu,
    text: defaultRichTextConfig.menu.text?.filter(
      (item) => item.title !== "Bold"
    ),
  },
  inlineMenu: {
    text: defaultRichTextConfig.inlineMenu.text?.filter(
      (item) => item.title !== "Bold"
    ),
    headings: defaultRichTextConfig.menu.headings,
  },
};

Proposal 1

Extend the existing implementation with the addition of menu and inlineMenu field props. For grouping I am suggesting we flatten default config and map against the new props defining spacers manually with something like a null value. This way, there it's simple adjust the two menus e.g.:

const RichTextInner: ComponentConfig<RichTextProps> = {
  fields: {
    richtext: {
      type: "richtext",
      contentEditable: true,
      menu: [
        "Bold",
        "Italic",
        null, // spacer
        "AlignLeft"
        "AlignRight"
        ...
      ]
      inlineMenu: [
        "Bold",
        "Italic"
        ...
      ]
    },
  },
  render: ({ richtext }) => {
    return <Section>{richtext}</Section>;
  },
  defaultProps: {
    richtext:
      "<p style='text-align: center;'><strong>✍️ <s>Plain-text</s> Rich Text Editor! 🎉</strong></p>",
  },
};

But then extra config and extensions can still be added to allow for extending of the editor.

import MyExtension from "./lib"

richtext: {
  type: "richtext",
  contentEditable: true,
  menu: [
    "Bold",
    "Italic",
    null, // spacer
    "AlignLeft"
    "AlignRight"
    "MyAction"
    ...
  ]
  inlineMenu: [
    "Bold",
    "Italic"
    ...
  ],
  config: [
    {
      title: "MyAction",
      icon: <Icon size={20} />,
      action: (e: Editor) => e.chain().setMyAction("something").run(),
      state: (e: Editor) => e.isActive({ myAction( "something" }),
    },
    ...// custom config added here
  ],
  extensions: [
    MyExtension
  ]
},

The downside to this approach is that would need to render everything as a button and I don't know how I feel about the spacers as a null value

Proposal 2

Have the same props as per proposal one but add something similar to component categories in config.

import MyExtension from "./lib"

richtext: {
  type: "richtext",
  contentEditable: true,
  menu: {
    alignment: {
      items: ["AlignLeft", "AlignCenter", "AlignRight", "Justify"],
    },
    typography: {
      type: "dropdown" // default button, button || dropdown
      items: ["p", "h2", "h3", ...],
    },
    other: {
      items: ["InlineCode", "BulletList", ...],
    },
  },
  inlineMenu: {
    alignment: {
      items: ["AlignLeft", "AlignCenter", "AlignRight", "Justify"],
    },
    ...
  }
  config: [
    {
      title: "MyAction",
      icon: <Icon size={20} />,
      action: (e: Editor) => e.chain().setMyAction("something").run(),
      state: (e: Editor) => e.isActive({ myAction( "something" }),
    },
    ...// custom config added here
  ],
  extensions: [
    MyExtension
  ]
},

Proposal 3

As above but allow for full manipulation of the config and extensions as a resolve function.

import MyExtension from "./lib"

richtext: {
  type: "richtext",
  contentEditable: true,
  menu: {
    alignment: {
      items: ["AlignLeft", "AlignCenter", "AlignRight", "Justify"],
    },
    typography: {
      type: "dropdown" // default button, button || dropdown
      items: ["p", "h2", "h3", ...],
    },
    other: {
      items: ["InlineCode", "BulletList", ...],
    },
  },
  inlineMenu: {
    alignment: {
      items: ["AlignLeft", "AlignCenter", "AlignRight", "Justify"],
    },
    ...
  }
  resolveConfig: (config) =>  [
    ...config,
    {
      title: "MyAction",
      icon: <Icon size={20} />,
      action: (e: Editor) => e.chain().setMyAction("something").run(),
      state: (e: Editor) => e.isActive({ myAction( "something" }),
    },
    ...// custom config added here
  ],
  resolveExtensions: (extensions) =>  [
    extensions.StarterKit,
    ...
    MyExtension
  ]
},

I'm personally leaning to option 2 but there may be some edge cases where users may want to unset existing config or extensions.

xaviemirmon avatar Oct 07 '25 10:10 xaviemirmon

Here's a 4th proposal that might work, forked from Proposal 2. This makes a few tweaks:

  1. Use inline | select for type field (button feels incorrect, as it's a group of buttons)
  2. Replace config with actions (more explicit)
  3. Provide full Tiptap API to action config via render function

Proposal 4

import MyExtension from "./lib"

richtext: {
  type: "richtext",
  contentEditable: true,
  menu: {
    alignment: {
      items: ["AlignLeft", "AlignCenter", "AlignRight", "Justify"],
    },
    typography: {
      type: "select" // default inline, inline || select
      items: ["p", "h2", "h3", ...],
    },
    other: {
      items: ["InlineCode", "BulletList", ...],
    },
  },
  inlineMenu: {
    alignment: {
      items: ["AlignLeft", "AlignCenter", "AlignRight", "Justify"],
    },
    ...
  }
  actions: {
    MyAction: {
      render: ({ editor }) => {
        return <IconButton onClick={() => editor.chain().focus().toggleBold().run()}>
          <Icon size={20} />
        </IconButton>
      }
    }
  },
  extensions: [
    MyExtension
  ]
},

chrisvxd avatar Oct 07 '25 10:10 chrisvxd

This looks good with the exception of the MyAction is structured. For Tiptap, every action has 3 primary commands to it.

Phase What you check/do Method(s) Purpose
State Is this active right now? editor.isActive(name, attrs?) Used to reflect UI state (e.g. highlight toolbar buttons)
Allowed Can this action be performed? editor.can().().run() Used to enable/disable UI controls safely
Execute Perform the action editor.chain().().run() Actually mutate the document

Also, the render would override what's being set in the menu config and I can't see how this would allow for the select type.

How about something like this?:

actions: {
    MyAction: {
      state: ({ editor }) => editor.isActive(name, attrs?)
      allowed: ({ editor }) => editor.can().<cmd>().run()
      can: ({ editor }) => editor.chain().<cmd>().run()
      icon: <Icon size={20} />
    }
  },

xaviemirmon avatar Oct 07 '25 12:10 xaviemirmon

After discussing here's what I think we've landed on on:

Proposal 5

import MyExtension from "./lib"

richtext: {
  type: "richtext",
  contentEditable: true,
  menu: {
    alignment: ["AlignLeft", "AlignCenter", "AlignRight", "Justify"],
    typography: ["TextSelect"],
    other:  ["InlineCode", "BulletList", ...]
  },
  inlineMenu: {
    alignment: ["AlignLeft", "AlignCenter", "AlignRight", "Justify"],
    typography: ["TextSelect"],
    ...
  },
  textSelectOptions: ["p", "h1", "h2", ... ]
  controls: {
    MyAction: {
      render: ({ editor }) => {
        return <IconButton onClick={() => editor.chain().focus().toggleBold().run()}>
          <Icon size={20} />
        </IconButton>
      }
    }
  },
  extensions: [
    MyExtension
  ]
},

xaviemirmon avatar Oct 08 '25 08:10 xaviemirmon

Hi is there any update about this? Puck 0.2 is already released and i could not find any info about rich editor.

I tried implementing it by myself with tiptap, but puckeditor does not allow me to use space button..

somegooser avatar Nov 20 '25 10:11 somegooser

Hey @somegooser! You can check the progress here #1341

FedericoBonel avatar Nov 20 '25 10:11 FedericoBonel

Closed by #1341! Available now in 0.21.0-canary.c78dc826 and will be released in 0.21.0.

chrisvxd avatar Dec 05 '25 12:12 chrisvxd