feat(static-renderer): add @tiptap/static-renderer to enable static rendering of content#5528
feat(static-renderer): add @tiptap/static-renderer to enable static rendering of content#5528nperez0111 merged 34 commits intonextfrom
@tiptap/static-renderer to enable static rendering of content#5528Conversation
🦋 Changeset detectedLatest commit: 639e0d7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 57 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
✅ Deploy Preview for tiptap-embed ready!
To edit notification comments on pull requests, go to your Netlify site configuration. |
2162826 to
64dae06
Compare
@tiptap/static-renderer to enable static rendering of content
|
Just as a note, if we render to something like jsx-dom then we can render directly to HTMLElements with pure JS & prosemirror |
|
@nperez0111 I am super excited about this PR (finally took a look at it over the weekend). I tried building it locally today to test it out, and it failed because there was no Edit: Ah, its the same error currently failing the build CI step. You are probably already aware of this then! |
Yep, this isn't completely prime-time yet. Needs a lot more tests to ensure things actually work |
474ff43 to
21fa3aa
Compare
|
@nperez0111 I'll remove my review - let me check if I should review in the future. |
5645687 to
bc7ad50
Compare
@tiptap/core
@tiptap/extension-bold
@tiptap/extension-blockquote
@tiptap/extension-bubble-menu
@tiptap/extension-bullet-list
@tiptap/extension-character-count
@tiptap/extension-code
@tiptap/extension-code-block
@tiptap/extension-code-block-lowlight
@tiptap/extension-collaboration
@tiptap/extension-collaboration-cursor
@tiptap/extension-color
@tiptap/extension-document
@tiptap/extension-dropcursor
@tiptap/extension-floating-menu
@tiptap/extension-focus
@tiptap/extension-font-family
@tiptap/extension-font-size
@tiptap/extension-gapcursor
@tiptap/extension-hard-break
@tiptap/extension-heading
@tiptap/extension-highlight
@tiptap/extension-history
@tiptap/extension-horizontal-rule
@tiptap/extension-image
@tiptap/extension-italic
@tiptap/extension-link
@tiptap/extension-list-item
@tiptap/extension-list-keymap
@tiptap/extension-mention
@tiptap/extension-ordered-list
@tiptap/extension-paragraph
@tiptap/extension-placeholder
@tiptap/extension-strike
@tiptap/extension-superscript
@tiptap/extension-table
@tiptap/extension-table-cell
@tiptap/extension-table-header
@tiptap/extension-table-row
@tiptap/extension-task-item
@tiptap/extension-task-list
@tiptap/extension-text
@tiptap/extension-text-align
@tiptap/extension-typography
@tiptap/extension-text-style
@tiptap/extension-utils
@tiptap/extension-underline
@tiptap/extension-youtube
@tiptap/html
@tiptap/react
@tiptap/pm
@tiptap/starter-kit
@tiptap/static-renderer
@tiptap/suggestion
@tiptap/vue-2
@tiptap/vue-3
@tiptap/extension-subscript
commit: |
479d895 to
d42fb8e
Compare
|
I think there is a bug when using namespaces: this should work: renderHTML({ HTMLAttributes, node }) {
const { iconValue, title } = node.attrs
return [
[
"div",
{
"data-type": ALERT_ICON_NODE,
},
[
"http://www.w3.org/2000/svg svg",
{
width: 24,
height: 24,
"stroke-width": 2,
stroke: "currentColor",
"stroke-linecap": "round",
fill: "none",
},
["title", {}, iconValue],
[
"use",
{ "http://www.w3.org/1999/xlink xlink:href": `#${iconValue}` },
],
],
],
] as any
}Specifically, the |
|
Welp, didn't know about that feature, I'll have to get back to you on this |
a65fba6 to
7d87fac
Compare
|
Thanks for the quick response! :) Not directly, since custom react extensions would look like But I found yesterday after posting, that |
bdbch
left a comment
There was a problem hiding this comment.
Looks good to me. I'm just not sure about all the example files. Is there a reason why they're here?
Afaik we don't have any example files in any of our other packages and we usually did this kind of thing in our demos.
There was a problem hiding this comment.
Do we need this file in src or is there any reason why we need this?
There was a problem hiding this comment.
Same here - do we need this in the src directory? We don't really expose anything to the user and this looks more like a code example we'd expect in the docs?
| return React.createElement( | ||
| component as React.FC<typeof props>, | ||
| // eslint-disable-next-line no-plusplus | ||
| Object.assign(props, { key: key++ }), |
There was a problem hiding this comment.
Instead of ignoring the linter we could just do key += 1 here
|
Yep, yea the .example files were just so I can test things while running it in bun. I'll see if it makes sense to make demos out of them |
|
what about vue? really dont want to use v-html |
|
Hi team 👋, I’m trying to test this PR using The error is triggered by this import line in import { NodeViewWrapper, NodeViewProps, NodeViewContent } from '@tiptap/react';From what I understand, this may be related to React Class Components not being compatible with Next.js Server Components. But since the extension is used in the editor context, I expected it to only run on the client. Repro context:
Questions:Is there a known issue when using Let me know if you need a minimal repo—I’d be happy to prepare one. Thanks for your awesome work! |
@volarname static renderer doesn't care what your output is. So you can implement it how you want. |
@guqing You are using the beta version of static renderer, but are you using the correct |
Yes, tiptap/react is also on the beta version. Here's a minimal reproducible demo: static-renderer-demo.zip ,It uses the code from this PR example The following error can be reproduced by accessing localhost:3000 in Node.js 18 or 22: If use NodeViewWrapper from tiptap/react, NodeViewContent will throw the same error |
|
I briefly looked into this @guqing, it seems to be that the RSCs impose a few additional constraints that the react package relies on:
So, this will not be viable to use custom node views via I no longer work for Tiptap & don't have the capacity to try make these changes myself. I'd suggest you to create a GitHub Issue and link back to my explanation here. Even contribute a PR if you can! Tagging @bdbch for visibility Your best option at the moment is to remove any references to import { renderToReactElement } from "@tiptap/static-renderer";
import StarterKit from "@tiptap/starter-kit";
import { Node } from "@tiptap/core";
// import { ReactNodeViewRenderer } from "@tiptap/react";
import React from "react";
// This component does not have a NodeViewContent, so it does not render it's children's rich text content
function MyCustomComponentWithoutContent() {
// RSCs do not support useState
// const [count, setCount] = React.useState(200);
return (
<div
className="custom-component-without-content"
// onClick={() => setCount((a) => a + 1)}
>
{/* {count} This is a react component! */}
This is a react component!
</div>
);
}
const CustomNodeExtensionWithoutContent = Node.create({
name: "customNodeExtensionWithoutContent",
atom: true,
renderHTML() {
return ["div", { class: "my-custom-component-without-content" }] as const;
},
// This extension cannot be used with RSCs if it uses the ReactNodeViewRenderer
// addNodeView() {
// return ReactNodeViewRenderer(MyCustomComponentWithoutContent);
// },
});
export default function Home() {
return renderToReactElement({
extensions: [StarterKit, CustomNodeExtensionWithoutContent],
options: {
nodeMapping: {
// render the custom node with the intended node view React component
customNodeExtensionWithoutContent: MyCustomComponentWithoutContent,
},
},
content: {
type: "doc",
content: [
{
type: "customNodeExtensionWithoutContent",
// rich text content
content: [
{
type: "text",
text: "Hello, world!",
},
],
},
],
},
});
}This example will correctly render the React component in an RSC. Also you specified your deps incorrectly it should be: "@tiptap/react": "3.0.0-beta.4",
"@tiptap/starter-kit": "3.0.0-beta.4",
"@tiptap/static-renderer": "3.0.0-beta.4",
"@tiptap/core": "3.0.0-beta.4",
"@tiptap/pm": "3.0.0-beta.4"For future reference: I asked an LLM to refactor the diff --git i/packages/react/src/EditorContent.tsx w/packages/react/src/EditorContent.tsx
index 39f220b5c..5a152784a 100644
--- i/packages/react/src/EditorContent.tsx
+++ w/packages/react/src/EditorContent.tsx
@@ -83,124 +83,107 @@ function getInstance(): ContentComponent {
}
}
-export class PureEditorContent extends React.Component<
- EditorContentProps,
- { hasContentComponentInitialized: boolean }
-> {
- editorContentRef: React.RefObject<any>
+export const PureEditorContent: React.FC<EditorContentProps> = props => {
+ const { editor: currentEditor, innerRef, ...rest } = props
+ const editorContentRef = React.useRef<HTMLDivElement>(null)
+ const initialized = React.useRef(false)
+ const unsubscribeToContentComponent = React.useRef<(() => void) | undefined>(undefined)
- initialized: boolean
+ const [hasContentComponentInitialized, setHasContentComponentInitialized] = React.useState(
+ Boolean((currentEditor as EditorWithContentComponent | null)?.contentComponent),
+ )
- unsubscribeToContentComponent?: () => void
+ React.useEffect(() => {
+ const editor = currentEditor as EditorWithContentComponent | null
- constructor(props: EditorContentProps) {
- super(props)
- this.editorContentRef = React.createRef()
- this.initialized = false
+ const init = () => {
+ if (editor && !editor.isDestroyed && editor.options.element) {
+ if (editor.contentComponent) {
+ return
+ }
- this.state = {
- hasContentComponentInitialized: Boolean((props.editor as EditorWithContentComponent | null)?.contentComponent),
+ const element = editorContentRef.current
+
+ if (element) {
+ element.append(...editor.options.element.childNodes)
+
+ editor.setOptions({
+ element,
+ })
+
+ editor.contentComponent = getInstance()
+
+ // Has the content component been initialized?
+ if (!hasContentComponentInitialized) {
+ // Subscribe to the content component
+ unsubscribeToContentComponent.current = editor.contentComponent.subscribe(() => {
+ setHasContentComponentInitialized(prev => {
+ if (!prev) {
+ return true
+ }
+ return prev
+ })
+
+ // Unsubscribe to previous content component
+ if (unsubscribeToContentComponent.current) {
+ unsubscribeToContentComponent.current()
+ }
+ })
+ }
+
+ editor.createNodeViews()
+ initialized.current = true
+ }
+ }
}
- }
- componentDidMount() {
- this.init()
- }
+ init()
- componentDidUpdate() {
- this.init()
- }
+ return () => {
+ // This is the cleanup function, equivalent to componentWillUnmount
+ const editorUnmount = currentEditor as EditorWithContentComponent | null
- init() {
- const editor = this.props.editor as EditorWithContentComponent | null
-
- if (editor && !editor.isDestroyed && editor.options.element) {
- if (editor.contentComponent) {
+ if (!editorUnmount) {
return
}
- const element = this.editorContentRef.current
+ initialized.current = false
- element.append(...editor.options.element.childNodes)
-
- editor.setOptions({
- element,
- })
-
- editor.contentComponent = getInstance()
-
- // Has the content component been initialized?
- if (!this.state.hasContentComponentInitialized) {
- // Subscribe to the content component
- this.unsubscribeToContentComponent = editor.contentComponent.subscribe(() => {
- this.setState(prevState => {
- if (!prevState.hasContentComponentInitialized) {
- return {
- hasContentComponentInitialized: true,
- }
- }
- return prevState
- })
-
- // Unsubscribe to previous content component
- if (this.unsubscribeToContentComponent) {
- this.unsubscribeToContentComponent()
- }
+ if (!editorUnmount.isDestroyed) {
+ editorUnmount.view.setProps({
+ nodeViews: {},
})
}
- editor.createNodeViews()
+ if (unsubscribeToContentComponent.current) {
+ unsubscribeToContentComponent.current()
+ }
- this.initialized = true
- }
- }
+ editorUnmount.contentComponent = null
- componentWillUnmount() {
- const editor = this.props.editor as EditorWithContentComponent | null
+ if (!editorUnmount.options.element?.firstChild) {
+ return
+ }
- if (!editor) {
- return
- }
+ // TODO using the new editor.mount method might allow us to remove this
+ const newElement = document.createElement('div')
- this.initialized = false
+ newElement.append(...editorUnmount.options.element.childNodes)
- if (!editor.isDestroyed) {
- editor.view.setProps({
- nodeViews: {},
+ editorUnmount.setOptions({
+ element: newElement,
})
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [currentEditor, hasContentComponentInitialized]) // Dependencies for the effect
- if (this.unsubscribeToContentComponent) {
- this.unsubscribeToContentComponent()
- }
-
- editor.contentComponent = null
-
- if (!editor.options.element?.firstChild) {
- return
- }
-
- // TODO using the new editor.mount method might allow us to remove this
- const newElement = document.createElement('div')
-
- newElement.append(...editor.options.element.childNodes)
-
- editor.setOptions({
- element: newElement,
- })
- }
-
- render() {
- const { editor, innerRef, ...rest } = this.props
-
- return (
- <>
- <div ref={mergeRefs(innerRef, this.editorContentRef)} {...rest} />
- {/* @ts-ignore */}
- {editor?.contentComponent && <Portals contentComponent={editor.contentComponent} />}
- </>
- )
- }
+ return (
+ <>
+ <div ref={mergeRefs(innerRef, editorContentRef)} {...rest} />
+ {/* @ts-ignore */}
+ {currentEditor?.contentComponent && <Portals contentComponent={currentEditor.contentComponent} />}
+ </>
+ )
}
// EditorContent should be re-created whenever the Editor instance changes
|
|
Hi @nperez0111 , Thanks again |
|
please document how to use static renderer with Vue. Its great to see it with React, but its not clear how to make it work with Vue.
|
Feel free to contribute, I don't know vue well enough to do that and have no need for this myself |
@tiptap/static-renderer
The
@tiptap/static-rendererpackage provides a way to render a Tiptap/ProseMirror document to any target format, like an HTML string, a React component, or even markdown. It does so, by taking the original JSON of a document (or document partial) and attempts to map this to the output format, by matching against a list of nodes & marks.Why Static Render?
The main use case for static rendering is to render a Tiptap/ProseMirror document on the server-side, for example in a Next.js or Nuxt.js application. This way, you can render the content of your editor to HTML before sending it to the client, which can improve the performance of your application.
Another use case is to render the content of your editor to another format like markdown, which can be useful if you want to send it to a markdown-based API.
But what makes it static? The static renderer doesn't require a browser or a DOM to render the content. It's a pure JavaScript function that takes a document (as JSON or Prosemirror Node instance) and returns the target format back.
Example
Render a Tiptap document to an HTML string:
Render to a React component:
There are a number of options available to customize the output, like custom node and mark mappings, or handling unhandled nodes and marks.
API
renderToHTMLStringrenderToHTMLStringOptionsextensions: An array of Tiptap extensions that are used to render the content.content: The content to render. Can be a Prosemirror Node instance or a JSON representation of a Prosemirror document.options: An object with additional options.options.nodeMapping: An object that maps Prosemirror nodes to HTML strings.options.markMapping: An object that maps Prosemirror marks to HTML strings.options.unhandledNode: A function that is called when an unhandled node is encountered.options.unhandledMark: A function that is called when an unhandled mark is encountered.renderToReactElementrenderToReactElementOptionsextensions: An array of Tiptap extensions that are used to render the content.content: The content to render. Can be a Prosemirror Node instance or a JSON representation of a Prosemirror document.options: An object with additional options.options.nodeMapping: An object that maps Prosemirror nodes to React components.options.markMapping: An object that maps Prosemirror marks to React components.options.unhandledNode: A function that is called when an unhandled node is encountered.options.unhandledMark: A function that is called when an unhandled mark is encountered.How does it work?
Each Tiptap node/mark extension can define a
renderHTMLmethod which is used to generate default mappings of Prosemirror nodes/marks to the target format. These can be overridden by providing custom mappings in the options. One thing to note is that the static renderer doesn't support node views automatically, so you need to provide a mapping for each node type that you want rendered as a node view. Here is an example of how you can render a node view as a React component:But what if you want to render the rich text content of the node view? You can do that by providing a
NodeViewContentcomponent as a child of the node view component: