mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-12 21:00:02 +08:00
feat(static-renderer): add @tiptap/static-renderer
to enable static rendering of content (#5528)
Some checks failed
build / build (20) (push) Has been cancelled
Publish / Release (20) (push) Has been cancelled
build / test (20, map[name:Demos/Commands spec:./demos/src/Commands/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Examples spec:./demos/src/Examples/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Experiments spec:./demos/src/Experiments/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Extensions spec:./demos/src/Extensions/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/GuideContent spec:./demos/src/GuideContent/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/GuideGettingStarted spec:./demos/src/GuideGettingStarted/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Marks spec:./demos/src/Marks/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Nodes spec:./demos/src/Nodes/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Integration spec:./tests/cypress/integration/**/*.spec.{js,ts}]) (push) Has been cancelled
build / release (20) (push) Has been cancelled
Some checks failed
build / build (20) (push) Has been cancelled
Publish / Release (20) (push) Has been cancelled
build / test (20, map[name:Demos/Commands spec:./demos/src/Commands/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Examples spec:./demos/src/Examples/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Experiments spec:./demos/src/Experiments/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Extensions spec:./demos/src/Extensions/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/GuideContent spec:./demos/src/GuideContent/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/GuideGettingStarted spec:./demos/src/GuideGettingStarted/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Marks spec:./demos/src/Marks/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Nodes spec:./demos/src/Nodes/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Integration spec:./tests/cypress/integration/**/*.spec.{js,ts}]) (push) Has been cancelled
build / release (20) (push) Has been cancelled
This commit is contained in:
parent
321b621b5c
commit
6a53bb2699
273
.changeset/blue-shrimps-rush.md
Normal file
273
.changeset/blue-shrimps-rush.md
Normal file
@ -0,0 +1,273 @@
|
||||
---
|
||||
'@tiptap/static-renderer': major
|
||||
---
|
||||
|
||||
# @tiptap/static-renderer
|
||||
|
||||
The `@tiptap/static-renderer` package 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:
|
||||
|
||||
```js
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { renderToHTMLString } from '@tiptap/static-renderer'
|
||||
|
||||
renderToHTMLString({
|
||||
extensions: [StarterKit], // using your extensions
|
||||
// we can map nodes and marks to HTML elements
|
||||
options: {
|
||||
nodeMapping: {
|
||||
// custom node mappings
|
||||
},
|
||||
markMapping: {
|
||||
// custom mark mappings
|
||||
},
|
||||
unhandledNode: ({ node }) => {
|
||||
// handle unhandled nodes
|
||||
return `[unknown node ${node.type.name}]`
|
||||
},
|
||||
unhandledMark: ({ mark }) => {
|
||||
// handle unhandled marks
|
||||
return `[unknown node ${mark.type.name}]`
|
||||
},
|
||||
},
|
||||
// the source content to render
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello World!',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
// returns: '<p>Hello World!</p>'
|
||||
```
|
||||
|
||||
Render to a React component:
|
||||
|
||||
```js
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { renderToReactElement } from '@tiptap/static-renderer'
|
||||
|
||||
renderToReactElement({
|
||||
extensions: [StarterKit], // using your extensions
|
||||
// we can map nodes and marks to HTML elements
|
||||
options: {
|
||||
nodeMapping: {
|
||||
// custom node mappings
|
||||
},
|
||||
markMapping: {
|
||||
// custom mark mappings
|
||||
},
|
||||
unhandledNode: ({ node }) => {
|
||||
// handle unhandled nodes
|
||||
return `[unknown node ${node.type.name}]`
|
||||
},
|
||||
unhandledMark: ({ mark }) => {
|
||||
// handle unhandled marks
|
||||
return `[unknown node ${mark.type.name}]`
|
||||
},
|
||||
},
|
||||
// the source content to render
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello World!',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
// returns a react node that, when evaluated, would be equivalent to: '<p>Hello World!</p>'
|
||||
```
|
||||
|
||||
There are a number of options available to customize the output, like custom node and mark mappings, or handling unhandled nodes and marks.
|
||||
|
||||
## API
|
||||
|
||||
### `renderToHTMLString`
|
||||
|
||||
```ts
|
||||
function renderToHTMLString(options: {
|
||||
extensions: Extension[],
|
||||
content: ProsemirrorNode | JSONContent,
|
||||
options?: TiptapHTMLStaticRendererOptions,
|
||||
}): string
|
||||
```
|
||||
|
||||
#### `renderToHTMLString` Options
|
||||
|
||||
- `extensions`: 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.
|
||||
|
||||
### `renderToReactElement`
|
||||
|
||||
```ts
|
||||
function renderToReactElement(options: {
|
||||
extensions: Extension[],
|
||||
content: ProsemirrorNode | JSONContent,
|
||||
options?: TiptapReactStaticRendererOptions,
|
||||
}): ReactElement
|
||||
```
|
||||
|
||||
#### `renderToReactElement` Options
|
||||
|
||||
- `extensions`: 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 `renderHTML` method 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:
|
||||
|
||||
```js
|
||||
import { Node } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { renderToReactElement } from '@tiptap/static-renderer'
|
||||
|
||||
// This component does not have a NodeViewContent, so it does not render it's children's rich text content
|
||||
function MyCustomComponentWithoutContent() {
|
||||
const [count, setCount] = React.useState(200)
|
||||
|
||||
return (
|
||||
<div className='custom-component-without-content' onClick={() => setCount(a => a + 1)}>
|
||||
{count} 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
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MyCustomComponentWithoutContent)
|
||||
},
|
||||
})
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
// returns: <div class="my-custom-component-without-content">200 This is a react component!</div>
|
||||
```
|
||||
|
||||
But what if you want to render the rich text content of the node view? You can do that by providing a `NodeViewContent` component as a child of the node view component:
|
||||
|
||||
```js
|
||||
import { Node } from '@tiptap/core'
|
||||
import {
|
||||
NodeViewContent,
|
||||
ReactNodeViewContentProvider,
|
||||
ReactNodeViewRenderer
|
||||
} from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { renderToReactElement } from '@tiptap/static-renderer'
|
||||
|
||||
|
||||
// This component does have a NodeViewContent, so it will render it's children's rich text content
|
||||
function MyCustomComponentWithContent() {
|
||||
return (
|
||||
<div className="custom-component-with-content">
|
||||
Custom component with content in React!
|
||||
<NodeViewContent />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const CustomNodeExtensionWithContent = Node.create({
|
||||
name: 'customNodeExtensionWithContent',
|
||||
content: 'text*',
|
||||
group: 'block',
|
||||
renderHTML() {
|
||||
return ['div', { class: 'my-custom-component-with-content' }, 0] as const
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MyCustomComponentWithContent)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
renderToReactElement({
|
||||
extensions: [StarterKit, CustomNodeExtensionWithContent],
|
||||
options: {
|
||||
nodeMapping: {
|
||||
customNodeExtensionWithContent: ({ children }) => {
|
||||
// To pass the content down into the NodeViewContent component, we need to wrap the custom component with the ReactNodeViewContentProvider
|
||||
return (
|
||||
<ReactNodeViewContentProvider content={children}>
|
||||
<MyCustomComponentWithContent />
|
||||
</ReactNodeViewContentProvider>
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'customNodeExtensionWithContent',
|
||||
// rich text content
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello, world!',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// returns: <div class="custom-component-with-content">Custom component with content in React!<div data-node-view-content="" style="white-space:pre-wrap">Hello, world!</div></div>
|
||||
// Note: The NodeViewContent component is rendered as a div with the attribute data-node-view-content, and the rich text content is rendered inside of it
|
||||
```
|
@ -1,8 +1,8 @@
|
||||
---
|
||||
"@tiptap/extension-table-header": minor
|
||||
"@tiptap/extension-table-cell": minor
|
||||
"@tiptap/extension-table-row": minor
|
||||
"@tiptap/extension-table": minor
|
||||
'@tiptap/extension-table-header': minor
|
||||
'@tiptap/extension-table-cell': minor
|
||||
'@tiptap/extension-table-row': minor
|
||||
'@tiptap/extension-table': minor
|
||||
---
|
||||
|
||||
This change repackages all of the table extensions to be within the `@tiptap/extension-table` package (other packages are just a re-export of the `@tiptap/extension-table` package). It also adds the `TableKit` export which will allow configuring the entire table with one extension.
|
||||
|
@ -1,5 +1,5 @@
|
||||
---
|
||||
"@tiptap/extension-text-style": patch
|
||||
'@tiptap/extension-text-style': patch
|
||||
---
|
||||
|
||||
The text-style extension, now will match elements with a style tag, but not consume them to allow other elements to match [per this comment](https://github.com/ueberdosis/tiptap/discussions/5912#discussioncomment-11716337).
|
||||
|
0
demos/src/Examples/StaticRendering/React/index.html
Normal file
0
demos/src/Examples/StaticRendering/React/index.html
Normal file
12
demos/src/Examples/StaticRendering/React/index.spec.js
Normal file
12
demos/src/Examples/StaticRendering/React/index.spec.js
Normal file
@ -0,0 +1,12 @@
|
||||
context('/src/Examples/StaticRendering/React/', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/src/Examples/StaticRendering/React/')
|
||||
})
|
||||
|
||||
it('should have a working tiptap instance', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
// eslint-disable-next-line
|
||||
expect(editor).to.not.be.null
|
||||
})
|
||||
})
|
||||
})
|
360
demos/src/Examples/StaticRendering/React/index.tsx
Normal file
360
demos/src/Examples/StaticRendering/React/index.tsx
Normal file
@ -0,0 +1,360 @@
|
||||
import './styles.scss'
|
||||
|
||||
import { Color } from '@tiptap/extension-color'
|
||||
import ListItem from '@tiptap/extension-list-item'
|
||||
import TextStyle from '@tiptap/extension-text-style'
|
||||
import { EditorProvider, JSONContent, useCurrentEditor, useEditorState } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { renderToHTMLString, renderToMarkdown, renderToReactElement } from '@tiptap/static-renderer'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const extensions = [StarterKit, Color.configure({ types: [TextStyle.name, ListItem.name] }), TextStyle]
|
||||
|
||||
const content = `
|
||||
<h2>
|
||||
Hi there,
|
||||
</h2>
|
||||
<p>
|
||||
this is a <em>basic</em> example of <strong>Tiptap</strong>. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
That’s a bullet list with one …
|
||||
</li>
|
||||
<li>
|
||||
… or two list items.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:
|
||||
</p>
|
||||
<pre><code class="language-css">body {
|
||||
display: none;
|
||||
}</code></pre>
|
||||
<p>
|
||||
I know, I know, this is impressive. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t forget to check the other examples too.
|
||||
</p>
|
||||
<blockquote>
|
||||
Wow, that’s amazing. Good work, boy! 👏
|
||||
<br />
|
||||
— Mom
|
||||
</blockquote>
|
||||
`
|
||||
|
||||
/**
|
||||
* This example demonstrates how to render a Prosemirror Node (or JSON Content) to a React Element.
|
||||
* It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method.
|
||||
* This can be useful if you want to render content to React without having an actual editor instance.
|
||||
*
|
||||
* You have complete control over the rendering process. And can replace how each Node/Mark is rendered.
|
||||
*/
|
||||
export default () => {
|
||||
const [tab, setTab] = useState<'react' | 'html' | 'html-element' | 'markdown'>('react')
|
||||
const [currentJSON, setJSON] = useState<JSONContent | null>(null)
|
||||
return (
|
||||
<div>
|
||||
<EditorProvider
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- Just want to show the usage first
|
||||
slotBefore={<MenuBar />}
|
||||
extensions={extensions}
|
||||
content={content}
|
||||
onUpdate={({ editor }) => {
|
||||
setJSON(editor.getJSON())
|
||||
}}
|
||||
></EditorProvider>
|
||||
|
||||
<div className="control-group">
|
||||
<h1>Rendered as:</h1>
|
||||
<div className="switch-group">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="option-switch"
|
||||
onChange={() => {
|
||||
setTab('react')
|
||||
}}
|
||||
checked={tab === 'react'}
|
||||
/>
|
||||
React
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="option-switch"
|
||||
onChange={() => {
|
||||
setTab('html')
|
||||
}}
|
||||
checked={tab === 'html'}
|
||||
/>
|
||||
HTML
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="option-switch"
|
||||
onChange={() => {
|
||||
setTab('html-element')
|
||||
}}
|
||||
checked={tab === 'html-element'}
|
||||
/>
|
||||
HTML Element
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="option-switch"
|
||||
onChange={() => {
|
||||
setTab('markdown')
|
||||
}}
|
||||
checked={tab === 'markdown'}
|
||||
/>
|
||||
Markdown
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{tab === 'react' && (
|
||||
<div className="output-group tiptap">
|
||||
<h2>React Element</h2>
|
||||
<p>This example renders the JSON content directly into a React element without using an editor instance.</p>
|
||||
<p className="hint">Notice that every paragraph now has a button counter</p>
|
||||
<div className="tiptap">
|
||||
{currentJSON &&
|
||||
renderToReactElement({
|
||||
content: currentJSON,
|
||||
extensions,
|
||||
options: {
|
||||
nodeMapping: {
|
||||
paragraph: ({ node }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [count, setCount] = useState(0)
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setCount(count + 1)} className="primary">
|
||||
CLICK ME
|
||||
</button>
|
||||
<p>Count is: {count}</p>
|
||||
<p>{node.textContent}</p>
|
||||
</>
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'html' && (
|
||||
<div className="output-group tiptap">
|
||||
<h2>HTML String</h2>
|
||||
<p>
|
||||
This example renders the JSON content into an HTML string without using an editor instance or document
|
||||
parser.
|
||||
</p>
|
||||
<pre>
|
||||
<code>
|
||||
{currentJSON &&
|
||||
renderToHTMLString({
|
||||
content: currentJSON,
|
||||
extensions,
|
||||
})}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'html-element' && (
|
||||
<div className="output-group tiptap">
|
||||
<h2>To HTML Element (via dangerouslySetInnerHTML)</h2>
|
||||
<p>
|
||||
This example renders the JSON content into an HTML string without using an editor instance or document
|
||||
parser, and places that result directly into the HTML using dangerouslySetInnerHTML.
|
||||
</p>
|
||||
<div
|
||||
className="tiptap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: currentJSON
|
||||
? renderToHTMLString({
|
||||
content: currentJSON,
|
||||
extensions,
|
||||
})
|
||||
: '',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'markdown' && (
|
||||
<div className="output-group tiptap">
|
||||
<h2>Markdown</h2>
|
||||
<p>
|
||||
This example renders the JSON content into a markdown without using an editor instance, document parser or
|
||||
markdown library.
|
||||
</p>
|
||||
<pre>
|
||||
<code>
|
||||
{currentJSON &&
|
||||
renderToMarkdown({
|
||||
content: currentJSON,
|
||||
extensions,
|
||||
})}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuBar() {
|
||||
const { editor } = useCurrentEditor()
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor: editor!,
|
||||
selector: ctx => {
|
||||
return {
|
||||
isBold: ctx.editor.isActive('bold'),
|
||||
canBold: ctx.editor.can().chain().focus().toggleBold().run(),
|
||||
isItalic: ctx.editor.isActive('italic'),
|
||||
canItalic: ctx.editor.can().chain().focus().toggleItalic().run(),
|
||||
isStrike: ctx.editor.isActive('strike'),
|
||||
canStrike: ctx.editor.can().chain().focus().toggleStrike().run(),
|
||||
isCode: ctx.editor.isActive('code'),
|
||||
canCode: ctx.editor.can().chain().focus().toggleCode().run(),
|
||||
canClearMarks: ctx.editor.can().chain().focus().unsetAllMarks().run(),
|
||||
isParagraph: ctx.editor.isActive('paragraph'),
|
||||
isHeading1: ctx.editor.isActive('heading', { level: 1 }),
|
||||
isHeading2: ctx.editor.isActive('heading', { level: 2 }),
|
||||
isHeading3: ctx.editor.isActive('heading', { level: 3 }),
|
||||
isHeading4: ctx.editor.isActive('heading', { level: 4 }),
|
||||
isHeading5: ctx.editor.isActive('heading', { level: 5 }),
|
||||
isHeading6: ctx.editor.isActive('heading', { level: 6 }),
|
||||
isBulletList: ctx.editor.isActive('bulletList'),
|
||||
isOrderedList: ctx.editor.isActive('orderedList'),
|
||||
isCodeBlock: ctx.editor.isActive('codeBlock'),
|
||||
isBlockquote: ctx.editor.isActive('blockquote'),
|
||||
canUndo: ctx.editor.can().chain().focus().undo().run(),
|
||||
canRedo: ctx.editor.can().chain().focus().redo().run(),
|
||||
isPurple: ctx.editor.isActive('textStyle', { color: '#958DF1' }),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="control-group">
|
||||
<div className="button-group">
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
disabled={!editorState.canBold}
|
||||
className={editorState.isBold ? 'is-active' : ''}
|
||||
>
|
||||
Bold
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
disabled={!editorState.canItalic}
|
||||
className={editorState.isItalic ? 'is-active' : ''}
|
||||
>
|
||||
Italic
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
disabled={!editorState.canStrike}
|
||||
className={editorState.isStrike ? 'is-active' : ''}
|
||||
>
|
||||
Strike
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
disabled={!editorState.canCode}
|
||||
className={editorState.isCode ? 'is-active' : ''}
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
<button onClick={() => editor.chain().focus().unsetAllMarks().run()}>Clear marks</button>
|
||||
<button onClick={() => editor.chain().focus().clearNodes().run()}>Clear nodes</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
className={editorState.isParagraph ? 'is-active' : ''}
|
||||
>
|
||||
Paragraph
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
className={editorState.isHeading1 ? 'is-active' : ''}
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={editorState.isHeading2 ? 'is-active' : ''}
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
className={editorState.isHeading3 ? 'is-active' : ''}
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
|
||||
className={editorState.isHeading4 ? 'is-active' : ''}
|
||||
>
|
||||
H4
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 5 }).run()}
|
||||
className={editorState.isHeading5 ? 'is-active' : ''}
|
||||
>
|
||||
H5
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 6 }).run()}
|
||||
className={editorState.isHeading6 ? 'is-active' : ''}
|
||||
>
|
||||
H6
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={editorState.isBulletList ? 'is-active' : ''}
|
||||
>
|
||||
Bullet list
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={editorState.isOrderedList ? 'is-active' : ''}
|
||||
>
|
||||
Ordered list
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
className={editorState.isCodeBlock ? 'is-active' : ''}
|
||||
>
|
||||
Code block
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
className={editorState.isBlockquote ? 'is-active' : ''}
|
||||
>
|
||||
Blockquote
|
||||
</button>
|
||||
<button onClick={() => editor.chain().focus().setHorizontalRule().run()}>Horizontal rule</button>
|
||||
<button onClick={() => editor.chain().focus().setHardBreak().run()}>Hard break</button>
|
||||
<button onClick={() => editor.chain().focus().undo().run()} disabled={!editorState.canUndo}>
|
||||
Undo
|
||||
</button>
|
||||
<button onClick={() => editor.chain().focus().redo().run()} disabled={!editorState.canRedo}>
|
||||
Redo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().setColor('#958DF1').run()}
|
||||
className={editorState.isPurple ? 'is-active' : ''}
|
||||
>
|
||||
Purple
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
92
demos/src/Examples/StaticRendering/React/styles.scss
Normal file
92
demos/src/Examples/StaticRendering/React/styles.scss
Normal file
@ -0,0 +1,92 @@
|
||||
/* Basic editor styles */
|
||||
.tiptap {
|
||||
:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* List styles */
|
||||
ul,
|
||||
ol {
|
||||
padding: 0 1rem;
|
||||
margin: 1.25rem 1rem 1.25rem 0.4rem;
|
||||
|
||||
li p {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
/* Heading styles */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1.1;
|
||||
margin-top: 2.5rem;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin-top: 3.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Code and preformatted text styles */
|
||||
code {
|
||||
background-color: var(--purple-light);
|
||||
border-radius: 0.4rem;
|
||||
color: var(--black);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25em 0.3em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--black);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--white);
|
||||
font-family: 'JetBrainsMono', monospace;
|
||||
margin: 1.5rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid var(--gray-3);
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--gray-2);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
context('/src/Examples/StaticRenderingAdvanced/React/', () => {
|
||||
before(() => {
|
||||
cy.visit('/src/Examples/StaticRenderingAdvanced/React/')
|
||||
})
|
||||
|
||||
it('should render the content as HTML', () => {
|
||||
cy.get('p').should('exist')
|
||||
})
|
||||
})
|
305
demos/src/Examples/StaticRenderingAdvanced/React/index.tsx
Normal file
305
demos/src/Examples/StaticRenderingAdvanced/React/index.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
import { Node, NodeViewContent, ReactNodeViewContentProvider, ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { renderToReactElement } from '@tiptap/static-renderer'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
// This component does not have a NodeViewContent, so it does not render it's children's rich text content
|
||||
function MyCustomComponentWithoutContent() {
|
||||
const [count, setCount] = React.useState(200)
|
||||
|
||||
return (
|
||||
<div className="custom-component-without-content">
|
||||
<button onClick={() => setCount(a => a + 1)}>Click me</button>
|
||||
{count} Custom component without content in React!
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// This component does have a NodeViewContent, so it will render it's children's rich text content
|
||||
function MyCustomComponentWithContent() {
|
||||
const [count, setCount] = React.useState(200)
|
||||
return (
|
||||
<div className="custom-component-with-content">
|
||||
<button onClick={() => setCount(a => a + 1)}>Click me</button>
|
||||
{count} Custom component with content in React!
|
||||
<NodeViewContent />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CustomNodeExtensionWithContent = Node.create({
|
||||
name: 'customNodeExtensionWithContent',
|
||||
content: 'text*',
|
||||
group: 'block',
|
||||
renderHTML() {
|
||||
return ['div', { class: 'my-custom-component-with-content' }, 0] as const
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MyCustomComponentWithContent)
|
||||
},
|
||||
})
|
||||
|
||||
const CustomNodeExtensionWithoutContent = Node.create({
|
||||
name: 'customNodeExtensionWithoutContent',
|
||||
atom: true,
|
||||
renderHTML() {
|
||||
return ['div', { class: 'my-custom-component-without-content' }] as const
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MyCustomComponentWithoutContent)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* This example demonstrates how to render a Prosemirror Node (or JSON Content) to a React Element.
|
||||
* It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method.
|
||||
* This can be useful if you want to render content to React without having an actual editor instance.
|
||||
*
|
||||
* You have complete control over the rendering process. And can replace how each Node/Mark is rendered.
|
||||
*/
|
||||
export default () => {
|
||||
const output = useMemo(() => {
|
||||
return renderToReactElement({
|
||||
extensions: [StarterKit, CustomNodeExtensionWithContent, CustomNodeExtensionWithoutContent],
|
||||
options: {
|
||||
nodeMapping: {
|
||||
// You can replace the rendering of a node with a custom react component
|
||||
heading({ node, children }) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [show, setEnabled] = React.useState(false)
|
||||
|
||||
return (
|
||||
<h1 {...node.attrs} onClick={() => setEnabled(true)}>
|
||||
{show ? `100% you can use React hooks!` : `Can you use React hooks? Click to find out!`} {children}
|
||||
</h1>
|
||||
)
|
||||
},
|
||||
// Node views are not supported in the static renderer, so you need to supply the custom component yourself
|
||||
customNodeExtensionWithContent({ children }) {
|
||||
return (
|
||||
<ReactNodeViewContentProvider content={children}>
|
||||
<MyCustomComponentWithContent />
|
||||
</ReactNodeViewContentProvider>
|
||||
)
|
||||
},
|
||||
customNodeExtensionWithoutContent() {
|
||||
return <MyCustomComponentWithoutContent />
|
||||
},
|
||||
},
|
||||
markMapping: {},
|
||||
},
|
||||
content: {
|
||||
type: 'doc',
|
||||
from: 0,
|
||||
to: 574,
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
from: 0,
|
||||
to: 11,
|
||||
attrs: {
|
||||
level: 2,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
from: 1,
|
||||
to: 10,
|
||||
text: 'Hi there,',
|
||||
},
|
||||
],
|
||||
},
|
||||
// This is a custom node extension with content
|
||||
{
|
||||
type: 'customNodeExtensionWithContent',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'MY CUSTOM COMPONENT CONTENT!!!',
|
||||
},
|
||||
],
|
||||
},
|
||||
// This is a custom node extension without content
|
||||
{
|
||||
type: 'customNodeExtensionWithoutContent',
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
from: 11,
|
||||
to: 169,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
from: 12,
|
||||
to: 22,
|
||||
text: 'this is a ',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
from: 22,
|
||||
to: 27,
|
||||
marks: [
|
||||
{
|
||||
type: 'italic',
|
||||
},
|
||||
],
|
||||
text: 'basic',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
from: 27,
|
||||
to: 39,
|
||||
text: ' example of ',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
from: 39,
|
||||
to: 45,
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
},
|
||||
],
|
||||
text: 'Tiptap',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
from: 45,
|
||||
to: 168,
|
||||
text: '. Sure, there are all kind of basic text styles you’d probably expect from a text editor. But wait until you see the lists:',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'bulletList',
|
||||
from: 169,
|
||||
to: 230,
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
from: 170,
|
||||
to: 205,
|
||||
attrs: {
|
||||
color: '',
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
from: 171,
|
||||
to: 204,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
from: 172,
|
||||
to: 203,
|
||||
text: 'That’s a bullet list with one …',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
from: 205,
|
||||
to: 229,
|
||||
attrs: {
|
||||
color: '',
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
from: 206,
|
||||
to: 228,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
from: 207,
|
||||
to: 227,
|
||||
text: '… or two list items.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
from: 230,
|
||||
to: 326,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
from: 231,
|
||||
to: 325,
|
||||
text: 'Isn’t that great? And all of that is editable. But wait, there’s more. Let’s try a code block:',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'codeBlock',
|
||||
from: 326,
|
||||
to: 353,
|
||||
attrs: {
|
||||
language: 'css',
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
from: 327,
|
||||
to: 352,
|
||||
text: 'body {\n display: none;\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
from: 353,
|
||||
to: 522,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
from: 354,
|
||||
to: 521,
|
||||
text: 'I know, I know, this is impressive. It’s only the tip of the iceberg though. Give it a try and click a little bit around. Don’t forget to check the other examples too.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'blockquote',
|
||||
from: 522,
|
||||
to: 572,
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
from: 523,
|
||||
to: 571,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
from: 524,
|
||||
to: 564,
|
||||
text: 'Wow, that’s amazing. Good work, boy! 👏 ',
|
||||
},
|
||||
{
|
||||
type: 'hardBreak',
|
||||
from: 564,
|
||||
to: 565,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
from: 565,
|
||||
to: 570,
|
||||
text: '— Mom',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}, [])
|
||||
|
||||
return <div className="tiptap">{output}</div>
|
||||
}
|
@ -41,7 +41,7 @@ export default () => {
|
||||
Bold,
|
||||
// other extensions …
|
||||
])
|
||||
}, [json])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<pre>
|
||||
|
@ -3,5 +3,9 @@ context('/src/GuideContent/GenerateHTML/React/', () => {
|
||||
cy.visit('/src/GuideContent/GenerateHTML/React/')
|
||||
})
|
||||
|
||||
// TODO: Write tests
|
||||
it('should render the content as an HTML string', () => {
|
||||
cy.get('pre code').should('exist')
|
||||
|
||||
cy.get('pre code').should('contain', '<p>Example <strong>Text</strong></p>')
|
||||
})
|
||||
})
|
||||
|
@ -3,5 +3,9 @@ context('/src/GuideContent/GenerateHTML/Vue/', () => {
|
||||
cy.visit('/src/GuideContent/GenerateHTML/Vue/')
|
||||
})
|
||||
|
||||
// TODO: Write tests
|
||||
it('should render the content as an HTML string', () => {
|
||||
cy.get('pre code').should('exist')
|
||||
|
||||
cy.get('pre code').should('contain', '<p>Example <strong>Text</strong></p>')
|
||||
})
|
||||
})
|
||||
|
@ -19,7 +19,7 @@ export default () => {
|
||||
Bold,
|
||||
// other extensions …
|
||||
])
|
||||
}, [html])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<pre>
|
||||
|
@ -3,5 +3,34 @@ context('/src/GuideContent/GenerateJSON/React/', () => {
|
||||
cy.visit('/src/GuideContent/GenerateJSON/React/')
|
||||
})
|
||||
|
||||
// TODO: Write tests
|
||||
it('should render the content as an HTML string', () => {
|
||||
cy.get('pre code').should('exist')
|
||||
|
||||
cy.get('pre code').should(
|
||||
'contain',
|
||||
`{
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Example "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold"
|
||||
}
|
||||
],
|
||||
"text": "Text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@ -3,5 +3,34 @@ context('/src/GuideContent/GenerateJSON/Vue/', () => {
|
||||
cy.visit('/src/GuideContent/GenerateJSON/Vue/')
|
||||
})
|
||||
|
||||
// TODO: Write tests
|
||||
it('should render the content as an HTML string', () => {
|
||||
cy.get('pre code').should('exist')
|
||||
|
||||
cy.get('pre code').should(
|
||||
'contain',
|
||||
`{
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Example "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold"
|
||||
}
|
||||
],
|
||||
"text": "Text"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@ -52,7 +52,7 @@ export default () => {
|
||||
blockSeparator: '\n\n',
|
||||
},
|
||||
)
|
||||
}, [json])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<pre>
|
||||
|
11
demos/src/GuideContent/StaticRenderHTML/React/index.spec.js
Normal file
11
demos/src/GuideContent/StaticRenderHTML/React/index.spec.js
Normal file
@ -0,0 +1,11 @@
|
||||
context('/src/GuideContent/StaticRenderHTML/React/', () => {
|
||||
before(() => {
|
||||
cy.visit('/src/GuideContent/StaticRenderHTML/React/')
|
||||
})
|
||||
|
||||
it('should render the content as an HTML string', () => {
|
||||
cy.get('pre code').should('exist')
|
||||
|
||||
cy.get('pre code').should('contain', '<p>Example <strong>Text</strong></p>')
|
||||
})
|
||||
})
|
58
demos/src/GuideContent/StaticRenderHTML/React/index.tsx
Normal file
58
demos/src/GuideContent/StaticRenderHTML/React/index.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import Bold from '@tiptap/extension-bold'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { renderToHTMLString } from '@tiptap/static-renderer'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example ',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
},
|
||||
],
|
||||
text: 'Text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* This example demonstrates how to render a Prosemirror Node (or JSON Content) to an HTML string.
|
||||
* It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method.
|
||||
* This can be useful if you want to render content to HTML without having an actual editor instance.
|
||||
*
|
||||
* You have complete control over the rendering process. And can replace how each Node/Mark is rendered.
|
||||
*/
|
||||
export default () => {
|
||||
const output = useMemo(() => {
|
||||
return renderToHTMLString({
|
||||
content: json,
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Bold,
|
||||
// other extensions …
|
||||
],
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<pre>
|
||||
<code>{output}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
11
demos/src/GuideContent/StaticRenderHTML/Vue/index.spec.js
Normal file
11
demos/src/GuideContent/StaticRenderHTML/Vue/index.spec.js
Normal file
@ -0,0 +1,11 @@
|
||||
context('/src/GuideContent/GenerateHTML/Vue/', () => {
|
||||
before(() => {
|
||||
cy.visit('/src/GuideContent/GenerateHTML/Vue/')
|
||||
})
|
||||
|
||||
it('should render the content as an HTML string', () => {
|
||||
cy.get('pre code').should('exist')
|
||||
|
||||
cy.get('pre code').should('contain', '<p>Example <strong>Text</strong></p>')
|
||||
})
|
||||
})
|
59
demos/src/GuideContent/StaticRenderHTML/Vue/index.vue
Normal file
59
demos/src/GuideContent/StaticRenderHTML/Vue/index.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<pre><code>{{ output }}</code></pre>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Bold from '@tiptap/extension-bold'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { renderToHTMLString } from '@tiptap/static-renderer'
|
||||
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example ',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
},
|
||||
],
|
||||
text: 'Text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* This example demonstrates how to render a Prosemirror Node (or JSON Content) to an HTML string.
|
||||
* It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method.
|
||||
* This can be useful if you want to render content to HTML without having an actual editor instance.
|
||||
*
|
||||
* You have complete control over the rendering process. And can replace how each Node/Mark is rendered.
|
||||
*/
|
||||
export default {
|
||||
computed: {
|
||||
output() {
|
||||
return renderToHTMLString({
|
||||
content: json,
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Bold,
|
||||
// other extensions …
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
13
demos/src/GuideContent/StaticRenderReact/React/index.spec.js
Normal file
13
demos/src/GuideContent/StaticRenderReact/React/index.spec.js
Normal file
@ -0,0 +1,13 @@
|
||||
context('/src/GuideContent/StaticRenderReact/React/', () => {
|
||||
before(() => {
|
||||
cy.visit('/src/GuideContent/StaticRenderReact/React/')
|
||||
})
|
||||
|
||||
it('should render the content as HTML', () => {
|
||||
cy.get('p').should('exist')
|
||||
cy.get('p').should('contain', 'Example')
|
||||
|
||||
cy.get('p strong').should('exist')
|
||||
cy.get('p strong').should('contain', 'Text')
|
||||
})
|
||||
})
|
54
demos/src/GuideContent/StaticRenderReact/React/index.tsx
Normal file
54
demos/src/GuideContent/StaticRenderReact/React/index.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import Bold from '@tiptap/extension-bold'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { renderToReactElement } from '@tiptap/static-renderer'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example ',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
},
|
||||
],
|
||||
text: 'Text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* This example demonstrates how to render a Prosemirror Node (or JSON Content) to a React Element.
|
||||
* It will use your extensions to render the content based on each Node's/Mark's `renderHTML` method.
|
||||
* This can be useful if you want to render content to React without having an actual editor instance.
|
||||
*
|
||||
* You have complete control over the rendering process. And can replace how each Node/Mark is rendered.
|
||||
*/
|
||||
export default () => {
|
||||
const output = useMemo(() => {
|
||||
return renderToReactElement({
|
||||
content: json,
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Bold,
|
||||
// other extensions …
|
||||
],
|
||||
})
|
||||
}, [])
|
||||
|
||||
return <div>{output}</div>
|
||||
}
|
@ -29,11 +29,13 @@ import { style } from './style.js'
|
||||
import {
|
||||
CanCommands,
|
||||
ChainedCommands,
|
||||
DocumentType,
|
||||
EditorEvents,
|
||||
EditorOptions,
|
||||
JSONContent,
|
||||
NodeType as TNodeType,
|
||||
SingleCommands,
|
||||
TextSerializer,
|
||||
TextType as TTextType,
|
||||
} from './types.js'
|
||||
import { createStyleTag } from './utilities/createStyleTag.js'
|
||||
import { isFunction } from './utilities/isFunction.js'
|
||||
@ -558,7 +560,10 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
/**
|
||||
* Get the document as JSON.
|
||||
*/
|
||||
public getJSON(): JSONContent {
|
||||
public getJSON(): DocumentType<
|
||||
Record<string, any> | undefined,
|
||||
TNodeType<string, undefined | Record<string, any>, any, (TNodeType | TTextType)[]>[]
|
||||
> {
|
||||
return this.state.doc.toJSON()
|
||||
}
|
||||
|
||||
|
@ -4,21 +4,25 @@ import { Plugin } from '@tiptap/pm/state'
|
||||
import { NodeViewConstructor } from '@tiptap/pm/view'
|
||||
|
||||
import type { Editor } from './Editor.js'
|
||||
import { getAttributesFromExtensions } from './helpers/getAttributesFromExtensions.js'
|
||||
import { getExtensionField } from './helpers/getExtensionField.js'
|
||||
import { getNodeType } from './helpers/getNodeType.js'
|
||||
import { getRenderedAttributes } from './helpers/getRenderedAttributes.js'
|
||||
import { getSchemaByResolvedExtensions } from './helpers/getSchemaByResolvedExtensions.js'
|
||||
import { getSchemaTypeByName } from './helpers/getSchemaTypeByName.js'
|
||||
import { isExtensionRulesEnabled } from './helpers/isExtensionRulesEnabled.js'
|
||||
import { splitExtensions } from './helpers/splitExtensions.js'
|
||||
import {
|
||||
flattenExtensions,
|
||||
getAttributesFromExtensions,
|
||||
getExtensionField,
|
||||
getNodeType,
|
||||
getRenderedAttributes,
|
||||
getSchemaByResolvedExtensions,
|
||||
getSchemaTypeByName,
|
||||
isExtensionRulesEnabled,
|
||||
resolveExtensions,
|
||||
sortExtensions,
|
||||
splitExtensions,
|
||||
} from './helpers/index.js'
|
||||
import type { NodeConfig } from './index.js'
|
||||
import { InputRule, inputRulesPlugin } from './InputRule.js'
|
||||
import { Mark } from './Mark.js'
|
||||
import { PasteRule, pasteRulesPlugin } from './PasteRule.js'
|
||||
import { AnyConfig, Extensions, RawCommands } from './types.js'
|
||||
import { callOrReturn } from './utilities/callOrReturn.js'
|
||||
import { findDuplicates } from './utilities/findDuplicates.js'
|
||||
|
||||
export class ExtensionManager {
|
||||
editor: Editor
|
||||
@ -31,83 +35,16 @@ export class ExtensionManager {
|
||||
|
||||
constructor(extensions: Extensions, editor: Editor) {
|
||||
this.editor = editor
|
||||
this.extensions = ExtensionManager.resolve(extensions)
|
||||
this.extensions = resolveExtensions(extensions)
|
||||
this.schema = getSchemaByResolvedExtensions(this.extensions, editor)
|
||||
this.setupExtensions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a flattened and sorted extension list while
|
||||
* also checking for duplicated extensions and warns the user.
|
||||
* @param extensions An array of Tiptap extensions
|
||||
* @returns An flattened and sorted array of Tiptap extensions
|
||||
*/
|
||||
static resolve(extensions: Extensions): Extensions {
|
||||
const resolvedExtensions = ExtensionManager.sort(ExtensionManager.flatten(extensions))
|
||||
const duplicatedNames = findDuplicates(resolvedExtensions.map(extension => extension.name))
|
||||
static resolve = resolveExtensions
|
||||
|
||||
if (duplicatedNames.length) {
|
||||
console.warn(
|
||||
`[tiptap warn]: Duplicate extension names found: [${duplicatedNames
|
||||
.map(item => `'${item}'`)
|
||||
.join(', ')}]. This can lead to issues.`,
|
||||
)
|
||||
}
|
||||
static sort = sortExtensions
|
||||
|
||||
return resolvedExtensions
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a flattened array of extensions by traversing the `addExtensions` field.
|
||||
* @param extensions An array of Tiptap extensions
|
||||
* @returns A flattened array of Tiptap extensions
|
||||
*/
|
||||
static flatten(extensions: Extensions): Extensions {
|
||||
return (
|
||||
extensions
|
||||
.map(extension => {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage,
|
||||
}
|
||||
|
||||
const addExtensions = getExtensionField<AnyConfig['addExtensions']>(extension, 'addExtensions', context)
|
||||
|
||||
if (addExtensions) {
|
||||
return [extension, ...this.flatten(addExtensions())]
|
||||
}
|
||||
|
||||
return extension
|
||||
})
|
||||
// `Infinity` will break TypeScript so we set a number that is probably high enough
|
||||
.flat(10)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort extensions by priority.
|
||||
* @param extensions An array of Tiptap extensions
|
||||
* @returns A sorted array of Tiptap extensions by priority
|
||||
*/
|
||||
static sort(extensions: Extensions): Extensions {
|
||||
const defaultPriority = 100
|
||||
|
||||
return extensions.sort((a, b) => {
|
||||
const priorityA = getExtensionField<AnyConfig['priority']>(a, 'priority') || defaultPriority
|
||||
const priorityB = getExtensionField<AnyConfig['priority']>(b, 'priority') || defaultPriority
|
||||
|
||||
if (priorityA > priorityB) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (priorityA < priorityB) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
static flatten = flattenExtensions
|
||||
|
||||
/**
|
||||
* Get all commands from the extensions.
|
||||
@ -148,7 +85,7 @@ export class ExtensionManager {
|
||||
// so it feels more natural to run plugins at the end of an array first.
|
||||
// That’s why we have to reverse the `extensions` array and sort again
|
||||
// based on the `priority` option.
|
||||
const extensions = ExtensionManager.sort([...this.extensions].reverse())
|
||||
const extensions = sortExtensions([...this.extensions].reverse())
|
||||
|
||||
const inputRules: InputRule[] = []
|
||||
const pasteRules: PasteRule[] = []
|
||||
|
30
packages/core/src/helpers/flattenExtensions.ts
Normal file
30
packages/core/src/helpers/flattenExtensions.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { AnyConfig, Extensions } from '../types.js'
|
||||
import { getExtensionField } from './getExtensionField.js'
|
||||
|
||||
/**
|
||||
* Create a flattened array of extensions by traversing the `addExtensions` field.
|
||||
* @param extensions An array of Tiptap extensions
|
||||
* @returns A flattened array of Tiptap extensions
|
||||
*/
|
||||
export function flattenExtensions(extensions: Extensions): Extensions {
|
||||
return (
|
||||
extensions
|
||||
.map(extension => {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage,
|
||||
}
|
||||
|
||||
const addExtensions = getExtensionField<AnyConfig['addExtensions']>(extension, 'addExtensions', context)
|
||||
|
||||
if (addExtensions) {
|
||||
return [extension, ...flattenExtensions(addExtensions())]
|
||||
}
|
||||
|
||||
return extension
|
||||
})
|
||||
// `Infinity` will break TypeScript so we set a number that is probably high enough
|
||||
.flat(10)
|
||||
)
|
||||
}
|
@ -1,12 +1,12 @@
|
||||
import { Schema } from '@tiptap/pm/model'
|
||||
|
||||
import { Editor } from '../Editor.js'
|
||||
import { ExtensionManager } from '../ExtensionManager.js'
|
||||
import { Extensions } from '../types.js'
|
||||
import { getSchemaByResolvedExtensions } from './getSchemaByResolvedExtensions.js'
|
||||
import { resolveExtensions } from './resolveExtensions.js'
|
||||
|
||||
export function getSchema(extensions: Extensions, editor?: Editor): Schema {
|
||||
const resolvedExtensions = ExtensionManager.resolve(extensions)
|
||||
const resolvedExtensions = resolveExtensions(extensions)
|
||||
|
||||
return getSchemaByResolvedExtensions(resolvedExtensions, editor)
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ export * from './findChildren.js'
|
||||
export * from './findChildrenInRange.js'
|
||||
export * from './findParentNode.js'
|
||||
export * from './findParentNodeClosestToPos.js'
|
||||
export * from './flattenExtensions.js'
|
||||
export * from './generateHTML.js'
|
||||
export * from './generateJSON.js'
|
||||
export * from './generateText.js'
|
||||
@ -45,7 +46,9 @@ export * from './isNodeEmpty.js'
|
||||
export * from './isNodeSelection.js'
|
||||
export * from './isTextSelection.js'
|
||||
export * from './posToDOMRect.js'
|
||||
export * from './resolveExtensions.js'
|
||||
export * from './resolveFocusPosition.js'
|
||||
export * from './rewriteUnknownContent.js'
|
||||
export * from './selectionToInsertionEnd.js'
|
||||
export * from './sortExtensions.js'
|
||||
export * from './splitExtensions.js'
|
||||
|
25
packages/core/src/helpers/resolveExtensions.ts
Normal file
25
packages/core/src/helpers/resolveExtensions.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Extensions } from '../types.js'
|
||||
import { findDuplicates } from '../utilities/findDuplicates.js'
|
||||
import { flattenExtensions } from './flattenExtensions.js'
|
||||
import { sortExtensions } from './sortExtensions.js'
|
||||
|
||||
/**
|
||||
* Returns a flattened and sorted extension list while
|
||||
* also checking for duplicated extensions and warns the user.
|
||||
* @param extensions An array of Tiptap extensions
|
||||
* @returns An flattened and sorted array of Tiptap extensions
|
||||
*/
|
||||
export function resolveExtensions(extensions: Extensions): Extensions {
|
||||
const resolvedExtensions = sortExtensions(flattenExtensions(extensions))
|
||||
const duplicatedNames = findDuplicates(resolvedExtensions.map(extension => extension.name))
|
||||
|
||||
if (duplicatedNames.length) {
|
||||
console.warn(
|
||||
`[tiptap warn]: Duplicate extension names found: [${duplicatedNames
|
||||
.map(item => `'${item}'`)
|
||||
.join(', ')}]. This can lead to issues.`,
|
||||
)
|
||||
}
|
||||
|
||||
return resolvedExtensions
|
||||
}
|
26
packages/core/src/helpers/sortExtensions.ts
Normal file
26
packages/core/src/helpers/sortExtensions.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { AnyConfig, Extensions } from '../types.js'
|
||||
import { getExtensionField } from './getExtensionField.js'
|
||||
|
||||
/**
|
||||
* Sort extensions by priority.
|
||||
* @param extensions An array of Tiptap extensions
|
||||
* @returns A sorted array of Tiptap extensions by priority
|
||||
*/
|
||||
export function sortExtensions(extensions: Extensions): Extensions {
|
||||
const defaultPriority = 100
|
||||
|
||||
return extensions.sort((a, b) => {
|
||||
const priorityA = getExtensionField<AnyConfig['priority']>(a, 'priority') || defaultPriority
|
||||
const priorityB = getExtensionField<AnyConfig['priority']>(b, 'priority') || defaultPriority
|
||||
|
||||
if (priorityA > priorityB) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (priorityA < priorityB) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
@ -149,6 +149,61 @@ export type JSONContent = {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* A mark type is either a JSON representation of a mark or a Prosemirror mark instance
|
||||
*/
|
||||
export type MarkType<
|
||||
Type extends string | { name: string } = any,
|
||||
Attributes extends undefined | Record<string, any> = any,
|
||||
> = {
|
||||
type: Type
|
||||
attrs: Attributes
|
||||
}
|
||||
|
||||
/**
|
||||
* A node type is either a JSON representation of a node or a Prosemirror node instance
|
||||
*/
|
||||
export type NodeType<
|
||||
Type extends string | { name: string } = any,
|
||||
Attributes extends undefined | Record<string, any> = any,
|
||||
NodeMarkType extends MarkType = any,
|
||||
Content extends (NodeType | TextType)[] = any,
|
||||
> = {
|
||||
type: Type
|
||||
attrs: Attributes
|
||||
content?: Content
|
||||
marks?: NodeMarkType[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A node type is either a JSON representation of a doc node or a Prosemirror doc node instance
|
||||
*/
|
||||
export type DocumentType<
|
||||
TDocAttributes extends Record<string, any> | undefined = Record<string, any>,
|
||||
TContentType extends NodeType[] = NodeType[],
|
||||
> = Omit<NodeType<'doc', TDocAttributes, never, TContentType>, 'marks' | 'content'> & { content: TContentType }
|
||||
|
||||
/**
|
||||
* A node type is either a JSON representation of a text node or a Prosemirror text node instance
|
||||
*/
|
||||
export type TextType<TMarkType extends MarkType = MarkType> = {
|
||||
type: 'text'
|
||||
text: string
|
||||
marks: TMarkType[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the output of a `renderHTML` function in prosemirror
|
||||
* @see https://prosemirror.net/docs/ref/#model.DOMOutputSpec
|
||||
*/
|
||||
export type DOMOutputSpecArray =
|
||||
| [string]
|
||||
| [string, Record<string, any>]
|
||||
| [string, 0]
|
||||
| [string, Record<string, any>, 0]
|
||||
| [string, Record<string, any>, DOMOutputSpecArray | 0]
|
||||
| [string, DOMOutputSpecArray]
|
||||
|
||||
export type Content = HTMLContent | JSONContent | JSONContent[] | null
|
||||
|
||||
export type CommandProps = {
|
||||
|
@ -1,4 +1,7 @@
|
||||
export function findDuplicates(items: any[]): any[] {
|
||||
/**
|
||||
* Find duplicates in an array.
|
||||
*/
|
||||
export function findDuplicates<T>(items: T[]): T[] {
|
||||
const filtered = items.filter((el, index) => items.indexOf(el) !== index)
|
||||
|
||||
return Array.from(new Set(filtered))
|
||||
|
@ -1,2 +1 @@
|
||||
# Change Log
|
||||
|
||||
|
@ -7,7 +7,7 @@
|
||||
|
||||
## Introduction
|
||||
|
||||
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as *New York Times*, *The Guardian* or *Atlassian*.
|
||||
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
|
||||
|
||||
## Official Documentation
|
||||
|
||||
|
@ -1,15 +1,16 @@
|
||||
import React from 'react'
|
||||
import React, { ComponentProps } from 'react'
|
||||
|
||||
import { useReactNodeView } from './useReactNodeView.js'
|
||||
|
||||
export interface NodeViewContentProps {
|
||||
[key: string]: any
|
||||
as?: React.ElementType
|
||||
}
|
||||
export type NodeViewContentProps<T extends keyof React.JSX.IntrinsicElements = 'div'> = {
|
||||
as?: NoInfer<T>
|
||||
} & ComponentProps<T>
|
||||
|
||||
export const NodeViewContent: React.FC<NodeViewContentProps> = props => {
|
||||
const Tag = props.as || 'div'
|
||||
const { nodeViewContentRef } = useReactNodeView()
|
||||
export function NodeViewContent<T extends keyof React.JSX.IntrinsicElements = 'div'>({
|
||||
as: Tag = 'div' as T,
|
||||
...props
|
||||
}: NodeViewContentProps<T>) {
|
||||
const { nodeViewContentRef, nodeViewContentChildren } = useReactNodeView()
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
@ -21,6 +22,8 @@ export const NodeViewContent: React.FC<NodeViewContentProps> = props => {
|
||||
whiteSpace: 'pre-wrap',
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{nodeViewContentChildren}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
@ -1,12 +1,27 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { createContext, createElement, ReactNode, useContext } from 'react'
|
||||
|
||||
export interface ReactNodeViewContextProps {
|
||||
onDragStart: (event: DragEvent) => void
|
||||
nodeViewContentRef: (element: HTMLElement | null) => void
|
||||
onDragStart?: (event: DragEvent) => void
|
||||
nodeViewContentRef?: (element: HTMLElement | null) => void
|
||||
/**
|
||||
* This allows you to add children into the NodeViewContent component.
|
||||
* This is useful when statically rendering the content of a node view.
|
||||
*/
|
||||
nodeViewContentChildren?: ReactNode
|
||||
}
|
||||
|
||||
export const ReactNodeViewContext = createContext<Partial<ReactNodeViewContextProps>>({
|
||||
onDragStart: undefined,
|
||||
export const ReactNodeViewContext = createContext<ReactNodeViewContextProps>({
|
||||
onDragStart: () => {
|
||||
// no-op
|
||||
},
|
||||
nodeViewContentChildren: undefined,
|
||||
nodeViewContentRef: () => {
|
||||
// no-op
|
||||
},
|
||||
})
|
||||
|
||||
export const ReactNodeViewContentProvider = ({ children, content }: { children: ReactNode; content: ReactNode }) => {
|
||||
return createElement(ReactNodeViewContext.Provider, { value: { nodeViewContentChildren: content } }, children)
|
||||
}
|
||||
|
||||
export const useReactNodeView = () => useContext(ReactNodeViewContext)
|
||||
|
1
packages/static-renderer/CHANGELOG.md
Normal file
1
packages/static-renderer/CHANGELOG.md
Normal file
@ -0,0 +1 @@
|
||||
# Change Log
|
18
packages/static-renderer/README.md
Normal file
18
packages/static-renderer/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# @tiptap/static-renderer
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/static-renderer)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/static-renderer)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
## Introduction
|
||||
|
||||
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
|
||||
|
||||
## Official Documentation
|
||||
|
||||
Documentation can be found on the [Tiptap website](https://tiptap.dev).
|
||||
|
||||
## License
|
||||
|
||||
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).
|
101
packages/static-renderer/package.json
Normal file
101
packages/static-renderer/package.json
Normal file
@ -0,0 +1,101 @@
|
||||
{
|
||||
"name": "@tiptap/static-renderer",
|
||||
"description": "statically render Tiptap JSON",
|
||||
"version": "3.0.0-next.1",
|
||||
"homepage": "https://tiptap.dev",
|
||||
"keywords": [
|
||||
"tiptap",
|
||||
"tiptap static renderer",
|
||||
"tiptap react renderer"
|
||||
],
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": {
|
||||
"import": "./dist/index.d.ts",
|
||||
"require": "./dist/index.d.cts"
|
||||
},
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./json/react": {
|
||||
"types": {
|
||||
"import": "./dist/json/react/index.d.ts",
|
||||
"require": "./dist/json/react/index.d.cts"
|
||||
},
|
||||
"import": "./dist/json/react/index.js",
|
||||
"require": "./dist/json/react/index.cjs"
|
||||
},
|
||||
"./json/html-string": {
|
||||
"types": {
|
||||
"import": "./dist/json/html-string/index.d.ts",
|
||||
"require": "./dist/json/html-string/index.d.cts"
|
||||
},
|
||||
"import": "./dist/json/html-string/index.js",
|
||||
"require": "./dist/json/html-string/index.cjs"
|
||||
},
|
||||
"./pm/react": {
|
||||
"types": {
|
||||
"import": "./dist/pm/react/index.d.ts",
|
||||
"require": "./dist/pm/react/index.d.cts"
|
||||
},
|
||||
"import": "./dist/pm/react/index.js",
|
||||
"require": "./dist/pm/react/index.cjs"
|
||||
},
|
||||
"./pm/html-string": {
|
||||
"types": {
|
||||
"import": "./dist/pm/html-string/index.d.ts",
|
||||
"require": "./dist/pm/html-string/index.d.cts"
|
||||
},
|
||||
"import": "./dist/pm/html-string/index.js",
|
||||
"require": "./dist/pm/html-string/index.cjs"
|
||||
},
|
||||
"./pm/markdown": {
|
||||
"types": {
|
||||
"import": "./dist/pm/markdown/index.d.ts",
|
||||
"require": "./dist/pm/markdown/index.d.cts"
|
||||
},
|
||||
"import": "./dist/pm/markdown/index.js",
|
||||
"require": "./dist/pm/markdown/index.cjs"
|
||||
}
|
||||
},
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "^3.0.0-next.1",
|
||||
"@tiptap/pm": "^3.0.0-next.1",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.0-next.1",
|
||||
"@tiptap/pm": "^3.0.0-next.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ueberdosis/tiptap",
|
||||
"directory": "packages/static-renderer"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"lint": "prettier ./src/ --check && eslint --cache --quiet --no-error-on-unmatched-pattern ./src/"
|
||||
}
|
||||
}
|
54
packages/static-renderer/src/helpers.ts
Normal file
54
packages/static-renderer/src/helpers.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { type ExtensionAttribute, type MarkType, type NodeType, mergeAttributes } from '@tiptap/core'
|
||||
|
||||
/**
|
||||
* This function returns the attributes of a node or mark that are defined by the given extension attributes.
|
||||
* @param nodeOrMark The node or mark to get the attributes from
|
||||
* @param extensionAttributes The extension attributes to use
|
||||
* @param onlyRenderedAttributes If true, only attributes that are rendered in the HTML are returned
|
||||
*/
|
||||
export function getAttributes(
|
||||
nodeOrMark: NodeType | MarkType,
|
||||
extensionAttributes: ExtensionAttribute[],
|
||||
onlyRenderedAttributes?: boolean,
|
||||
): Record<string, any> {
|
||||
const nodeOrMarkAttributes = nodeOrMark.attrs
|
||||
|
||||
if (!nodeOrMarkAttributes) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return extensionAttributes
|
||||
.filter(item => {
|
||||
if (item.type !== (typeof nodeOrMark.type === 'string' ? nodeOrMark.type : nodeOrMark.type.name)) {
|
||||
return false
|
||||
}
|
||||
if (onlyRenderedAttributes) {
|
||||
return item.attribute.rendered
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(item => {
|
||||
if (!item.attribute.renderHTML) {
|
||||
return {
|
||||
[item.name]: item.name in nodeOrMarkAttributes ? nodeOrMarkAttributes[item.name] : item.attribute.default,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
item.attribute.renderHTML(nodeOrMarkAttributes) || {
|
||||
[item.name]: item.name in nodeOrMarkAttributes ? nodeOrMarkAttributes[item.name] : item.attribute.default,
|
||||
}
|
||||
)
|
||||
})
|
||||
.reduce((attributes, attribute) => mergeAttributes(attributes, attribute), {})
|
||||
}
|
||||
|
||||
/**
|
||||
* This function returns the HTML attributes of a node or mark that are defined by the given extension attributes.
|
||||
* @param nodeOrMark The node or mark to get the attributes from
|
||||
* @param extensionAttributes The extension attributes to use
|
||||
*/
|
||||
export function getHTMLAttributes(nodeOrMark: NodeType | MarkType, extensionAttributes: ExtensionAttribute[]) {
|
||||
return getAttributes(nodeOrMark, extensionAttributes, true)
|
||||
}
|
6
packages/static-renderer/src/index.ts
Normal file
6
packages/static-renderer/src/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './helpers.js'
|
||||
export * from './json/html-string/index.js'
|
||||
export * from './json/react/index.js'
|
||||
export * from './pm/html-string/index.js'
|
||||
export * from './pm/markdown/index.js'
|
||||
export * from './pm/react/index.js'
|
2
packages/static-renderer/src/json/html-string/index.ts
Normal file
2
packages/static-renderer/src/json/html-string/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from '../renderer.js'
|
||||
export * from './string.js'
|
48
packages/static-renderer/src/json/html-string/string.ts
Normal file
48
packages/static-renderer/src/json/html-string/string.ts
Normal file
@ -0,0 +1,48 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { MarkType, NodeType } from '@tiptap/core'
|
||||
|
||||
import { TiptapStaticRenderer, TiptapStaticRendererOptions } from '../renderer.js'
|
||||
|
||||
export function renderJSONContentToString<
|
||||
/**
|
||||
* A mark type is either a JSON representation of a mark or a Prosemirror mark instance
|
||||
*/
|
||||
TMarkType extends { type: any } = MarkType,
|
||||
/**
|
||||
* A node type is either a JSON representation of a node or a Prosemirror node instance
|
||||
*/
|
||||
TNodeType extends {
|
||||
content?: { forEach: (cb: (node: TNodeType) => void) => void }
|
||||
marks?: readonly TMarkType[]
|
||||
type: string | { name: string }
|
||||
} = NodeType,
|
||||
>(options: TiptapStaticRendererOptions<string, TMarkType, TNodeType>) {
|
||||
return TiptapStaticRenderer(ctx => {
|
||||
return ctx.component(ctx.props as any)
|
||||
}, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the attributes of a node or mark to a string
|
||||
* @param attrs The attributes to serialize
|
||||
* @returns The serialized attributes as a string
|
||||
*/
|
||||
export function serializeAttrsToHTMLString(attrs: Record<string, any> | undefined | null): string {
|
||||
const output = Object.entries(attrs || {})
|
||||
.map(([key, value]) => `${key.split(' ').at(-1)}=${JSON.stringify(value)}`)
|
||||
.join(' ')
|
||||
|
||||
return output ? ` ${output}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the children of a node or mark to a string
|
||||
* @param children The children to serialize
|
||||
* @returns The serialized children as a string
|
||||
*/
|
||||
export function serializeChildrenToHTMLString(children?: string | string[]): string {
|
||||
return ([] as string[])
|
||||
.concat(children || '')
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
}
|
2
packages/static-renderer/src/json/react/index.ts
Normal file
2
packages/static-renderer/src/json/react/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from '../renderer.js'
|
||||
export * from './react.js'
|
32
packages/static-renderer/src/json/react/react.tsx
Normal file
32
packages/static-renderer/src/json/react/react.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { MarkType, NodeType } from '@tiptap/core'
|
||||
import React from 'react'
|
||||
|
||||
import { TiptapStaticRenderer, TiptapStaticRendererOptions } from '../renderer.js'
|
||||
|
||||
export function renderJSONContentToReactElement<
|
||||
/**
|
||||
* A mark type is either a JSON representation of a mark or a Prosemirror mark instance
|
||||
*/
|
||||
TMarkType extends { type: any } = MarkType,
|
||||
/**
|
||||
* A node type is either a JSON representation of a node or a Prosemirror node instance
|
||||
*/
|
||||
TNodeType extends {
|
||||
content?: { forEach: (cb: (node: TNodeType) => void) => void }
|
||||
marks?: readonly TMarkType[]
|
||||
type: string | { name: string }
|
||||
} = NodeType,
|
||||
>(options: TiptapStaticRendererOptions<React.ReactNode, TMarkType, TNodeType>) {
|
||||
let key = 0
|
||||
|
||||
return TiptapStaticRenderer<React.ReactNode, TMarkType, TNodeType>(({ component, props: { children, ...props } }) => {
|
||||
return React.createElement(
|
||||
component as React.FC<typeof props>,
|
||||
// eslint-disable-next-line no-plusplus
|
||||
Object.assign(props, { key: key++ }),
|
||||
([] as React.ReactNode[]).concat(children),
|
||||
)
|
||||
}, options)
|
||||
}
|
241
packages/static-renderer/src/json/renderer.ts
Normal file
241
packages/static-renderer/src/json/renderer.ts
Normal file
@ -0,0 +1,241 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { MarkType, NodeType } from '@tiptap/core'
|
||||
|
||||
/**
|
||||
* Props for a node renderer
|
||||
*/
|
||||
export type NodeProps<TNodeType = any, TChildren = any> = {
|
||||
/**
|
||||
* The current node to render
|
||||
*/
|
||||
node: TNodeType
|
||||
/**
|
||||
* Unless the node is the root node, this will always be defined
|
||||
*/
|
||||
parent?: TNodeType
|
||||
/**
|
||||
* The children of the current node
|
||||
*/
|
||||
children?: TChildren
|
||||
/**
|
||||
* Render a child element
|
||||
*/
|
||||
renderElement: (props: {
|
||||
/**
|
||||
* Tiptap JSON content to render
|
||||
*/
|
||||
content: TNodeType
|
||||
/**
|
||||
* The parent node of the current node
|
||||
*/
|
||||
parent?: TNodeType
|
||||
}) => TChildren
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for a mark renderer
|
||||
*/
|
||||
export type MarkProps<TMarkType = any, TChildren = any, TNodeType = any> = {
|
||||
/**
|
||||
* The current mark to render
|
||||
*/
|
||||
mark: TMarkType
|
||||
/**
|
||||
* The children of the current mark
|
||||
*/
|
||||
children?: TChildren
|
||||
/**
|
||||
* The node the current mark is applied to
|
||||
*/
|
||||
node: TNodeType
|
||||
/**
|
||||
* The node the current mark is applied to
|
||||
*/
|
||||
parent?: TNodeType
|
||||
}
|
||||
|
||||
export type TiptapStaticRendererOptions<
|
||||
/**
|
||||
* The return type of the render function (e.g. React.ReactNode, string)
|
||||
*/
|
||||
TReturnType,
|
||||
/**
|
||||
* A mark type is either a JSON representation of a mark or a Prosemirror mark instance
|
||||
*/
|
||||
TMarkType extends { type: any } = MarkType,
|
||||
/**
|
||||
* A node type is either a JSON representation of a node or a Prosemirror node instance
|
||||
*/
|
||||
TNodeType extends {
|
||||
content?: { forEach: (cb: (node: TNodeType) => void) => void }
|
||||
marks?: readonly TMarkType[]
|
||||
type: string | { name: string }
|
||||
} = NodeType,
|
||||
/**
|
||||
* A node renderer is a function that takes a node and its children and returns the rendered output
|
||||
*/
|
||||
TNodeRender extends (ctx: NodeProps<TNodeType, TReturnType | TReturnType[]>) => TReturnType = (
|
||||
ctx: NodeProps<TNodeType, TReturnType | TReturnType[]>,
|
||||
) => TReturnType,
|
||||
/**
|
||||
* A mark renderer is a function that takes a mark and its children and returns the rendered output
|
||||
*/
|
||||
TMarkRender extends (ctx: MarkProps<TMarkType, TReturnType | TReturnType[], TNodeType>) => TReturnType = (
|
||||
ctx: MarkProps<TMarkType, TReturnType | TReturnType[], TNodeType>,
|
||||
) => TReturnType,
|
||||
> = {
|
||||
/**
|
||||
* Mapping of node types to react components
|
||||
*/
|
||||
nodeMapping: Record<string, NoInfer<TNodeRender>>
|
||||
/**
|
||||
* Mapping of mark types to react components
|
||||
*/
|
||||
markMapping: Record<string, NoInfer<TMarkRender>>
|
||||
/**
|
||||
* Component to render if a node type is not handled
|
||||
*/
|
||||
unhandledNode?: NoInfer<TNodeRender>
|
||||
/**
|
||||
* Component to render if a mark type is not handled
|
||||
*/
|
||||
unhandledMark?: NoInfer<TMarkRender>
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiptap Static Renderer
|
||||
* ----------------------
|
||||
*
|
||||
* This function is a basis to allow for different renderers to be created.
|
||||
* Generic enough to be able to statically render Prosemirror JSON or Prosemirror Nodes.
|
||||
*
|
||||
* Using this function, you can create a renderer that takes a JSON representation of a Prosemirror document
|
||||
* and renders it using a mapping of node types to React components or even to a string.
|
||||
* This function is used as the basis to create the `reactRenderer` and `stringRenderer` functions.
|
||||
*/
|
||||
export function TiptapStaticRenderer<
|
||||
/**
|
||||
* The return type of the render function (e.g. React.ReactNode, string)
|
||||
*/
|
||||
TReturnType,
|
||||
/**
|
||||
* A mark type is either a JSON representation of a mark or a Prosemirror mark instance
|
||||
*/
|
||||
TMarkType extends { type: string | { name: string } } = MarkType,
|
||||
/**
|
||||
* A node type is either a JSON representation of a node or a Prosemirror node instance
|
||||
*/
|
||||
TNodeType extends {
|
||||
content?: { forEach: (cb: (node: TNodeType) => void) => void }
|
||||
marks?: readonly TMarkType[]
|
||||
type: string | { name: string }
|
||||
} = NodeType,
|
||||
/**
|
||||
* A node renderer is a function that takes a node and its children and returns the rendered output
|
||||
*/
|
||||
TNodeRender extends (ctx: NodeProps<TNodeType, TReturnType | TReturnType[]>) => TReturnType = (
|
||||
ctx: NodeProps<TNodeType, TReturnType | TReturnType[]>,
|
||||
) => TReturnType,
|
||||
/**
|
||||
* A mark renderer is a function that takes a mark and its children and returns the rendered output
|
||||
*/
|
||||
TMarkRender extends (ctx: MarkProps<TMarkType, TReturnType | TReturnType[], TNodeType>) => TReturnType = (
|
||||
ctx: MarkProps<TMarkType, TReturnType | TReturnType[], TNodeType>,
|
||||
) => TReturnType,
|
||||
>(
|
||||
/**
|
||||
* The function that actually renders the component
|
||||
*/
|
||||
renderComponent: (
|
||||
ctx:
|
||||
| {
|
||||
component: TNodeRender
|
||||
props: NodeProps<TNodeType, TReturnType | TReturnType[]>
|
||||
}
|
||||
| {
|
||||
component: TMarkRender
|
||||
props: MarkProps<TMarkType, TReturnType | TReturnType[], TNodeType>
|
||||
},
|
||||
) => TReturnType,
|
||||
{
|
||||
nodeMapping,
|
||||
markMapping,
|
||||
unhandledNode,
|
||||
unhandledMark,
|
||||
}: TiptapStaticRendererOptions<TReturnType, TMarkType, TNodeType, TNodeRender, TMarkRender>,
|
||||
) {
|
||||
/**
|
||||
* Render Tiptap JSON and all its children using the provided node and mark mappings.
|
||||
*/
|
||||
return function renderContent({
|
||||
content,
|
||||
parent,
|
||||
}: {
|
||||
/**
|
||||
* Tiptap JSON content to render
|
||||
*/
|
||||
content: TNodeType
|
||||
/**
|
||||
* The parent node of the current node
|
||||
*/
|
||||
parent?: TNodeType
|
||||
}): TReturnType {
|
||||
const nodeType = typeof content.type === 'string' ? content.type : content.type.name
|
||||
const NodeHandler = nodeMapping[nodeType] ?? unhandledNode
|
||||
|
||||
if (!NodeHandler) {
|
||||
throw new Error(`missing handler for node type ${nodeType}`)
|
||||
}
|
||||
|
||||
const nodeContent = renderComponent({
|
||||
component: NodeHandler,
|
||||
props: {
|
||||
node: content,
|
||||
parent,
|
||||
renderElement: renderContent,
|
||||
// Lazily compute the children to avoid unnecessary recursion
|
||||
get children() {
|
||||
// recursively render child content nodes
|
||||
const children: TReturnType[] = []
|
||||
|
||||
if (content.content) {
|
||||
content.content.forEach(child => {
|
||||
children.push(
|
||||
renderContent({
|
||||
content: child,
|
||||
parent: content,
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return children
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// apply marks to the content
|
||||
const markedContent = content.marks
|
||||
? content.marks.reduce((acc, mark) => {
|
||||
const markType = typeof mark.type === 'string' ? mark.type : mark.type.name
|
||||
const MarkHandler = markMapping[markType] ?? unhandledMark
|
||||
|
||||
if (!MarkHandler) {
|
||||
throw new Error(`missing handler for mark type ${markType}`)
|
||||
}
|
||||
|
||||
return renderComponent({
|
||||
component: MarkHandler,
|
||||
props: {
|
||||
mark,
|
||||
parent,
|
||||
node: content,
|
||||
children: acc,
|
||||
},
|
||||
})
|
||||
}, nodeContent)
|
||||
: nodeContent
|
||||
|
||||
return markedContent
|
||||
}
|
||||
}
|
212
packages/static-renderer/src/pm/extensionRenderer.ts
Normal file
212
packages/static-renderer/src/pm/extensionRenderer.ts
Normal file
@ -0,0 +1,212 @@
|
||||
/* eslint-disable no-plusplus */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import {
|
||||
ExtensionAttribute,
|
||||
Extensions,
|
||||
getAttributesFromExtensions,
|
||||
getExtensionField,
|
||||
getSchemaByResolvedExtensions,
|
||||
JSONContent,
|
||||
Mark as MarkExtension,
|
||||
MarkConfig,
|
||||
Node as NodeExtension,
|
||||
NodeConfig,
|
||||
resolveExtensions,
|
||||
splitExtensions,
|
||||
} from '@tiptap/core'
|
||||
import { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model'
|
||||
|
||||
import { getHTMLAttributes } from '../helpers.js'
|
||||
import { MarkProps, NodeProps, TiptapStaticRendererOptions } from '../json/renderer.js'
|
||||
|
||||
export type DomOutputSpecToElement<T> = (content: DOMOutputSpec) => (children?: T | T[]) => T
|
||||
|
||||
/**
|
||||
* This takes a NodeExtension and maps it to a React component
|
||||
* @param extension The node extension to map to a React component
|
||||
* @param extensionAttributes All available extension attributes
|
||||
* @returns A tuple with the name of the extension and a React component that renders the extension
|
||||
*/
|
||||
export function mapNodeExtensionToReactNode<T>(
|
||||
domOutputSpecToElement: DomOutputSpecToElement<T>,
|
||||
extension: NodeExtension,
|
||||
extensionAttributes: ExtensionAttribute[],
|
||||
options?: Partial<Pick<TiptapStaticRendererOptions<T, Mark, Node>, 'unhandledNode'>>,
|
||||
): [string, (props: NodeProps<Node, T | T[]>) => T] {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage,
|
||||
parent: extension.parent,
|
||||
}
|
||||
|
||||
const renderToHTML = getExtensionField<NodeConfig['renderHTML']>(extension, 'renderHTML', context)
|
||||
|
||||
if (!renderToHTML) {
|
||||
if (options?.unhandledNode) {
|
||||
return [extension.name, options.unhandledNode]
|
||||
}
|
||||
return [
|
||||
extension.name,
|
||||
() => {
|
||||
throw new Error(
|
||||
`[tiptap error]: Node ${extension.name} cannot be rendered, it is missing a "renderToHTML" method, please implement it or override the corresponding "nodeMapping" method to have a custom rendering`,
|
||||
)
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
extension.name,
|
||||
({ node, children }) => {
|
||||
try {
|
||||
return domOutputSpecToElement(
|
||||
renderToHTML({
|
||||
node,
|
||||
HTMLAttributes: getHTMLAttributes(node, extensionAttributes),
|
||||
}),
|
||||
)(children)
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[tiptap error]: Node ${
|
||||
extension.name
|
||||
} cannot be rendered, it's "renderToHTML" method threw an error: ${(e as Error).message}`,
|
||||
{ cause: e },
|
||||
)
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* This takes a MarkExtension and maps it to a React component
|
||||
* @param extension The mark extension to map to a React component
|
||||
* @param extensionAttributes All available extension attributes
|
||||
* @returns A tuple with the name of the extension and a React component that renders the extension
|
||||
*/
|
||||
export function mapMarkExtensionToReactNode<T>(
|
||||
domOutputSpecToElement: DomOutputSpecToElement<T>,
|
||||
extension: MarkExtension,
|
||||
extensionAttributes: ExtensionAttribute[],
|
||||
options?: Partial<Pick<TiptapStaticRendererOptions<T, Mark, Node>, 'unhandledMark'>>,
|
||||
): [string, (props: MarkProps<Mark, T | T[]>) => T] {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage,
|
||||
parent: extension.parent,
|
||||
}
|
||||
|
||||
const renderToHTML = getExtensionField<MarkConfig['renderHTML']>(extension, 'renderHTML', context)
|
||||
|
||||
if (!renderToHTML) {
|
||||
if (options?.unhandledMark) {
|
||||
return [extension.name, options.unhandledMark]
|
||||
}
|
||||
return [
|
||||
extension.name,
|
||||
() => {
|
||||
throw new Error(`Node ${extension.name} cannot be rendered, it is missing a "renderToHTML" method`)
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
extension.name,
|
||||
({ mark, children }) => {
|
||||
try {
|
||||
return domOutputSpecToElement(
|
||||
renderToHTML({
|
||||
mark,
|
||||
HTMLAttributes: getHTMLAttributes(mark, extensionAttributes),
|
||||
}),
|
||||
)(children)
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[tiptap error]: Mark ${
|
||||
extension.name
|
||||
} cannot be rendered, it's "renderToHTML" method threw an error: ${(e as Error).message}`,
|
||||
{ cause: e },
|
||||
)
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will statically render a Prosemirror Node to a target element type using the given extensions
|
||||
* @param renderer The renderer to use to render the Prosemirror Node to the target element type
|
||||
* @param domOutputSpecToElement A function that takes a Prosemirror DOMOutputSpec and returns a function that takes children and returns the target element type
|
||||
* @param mapDefinedTypes An object with functions to map the doc and text types to the target element type
|
||||
* @param content The Prosemirror Node to render
|
||||
* @param extensions The extensions to use to render the Prosemirror Node
|
||||
* @param options Additional options to pass to the renderer that can override the default behavior
|
||||
* @returns The rendered target element type
|
||||
*/
|
||||
export function renderToElement<T>({
|
||||
renderer,
|
||||
domOutputSpecToElement,
|
||||
mapDefinedTypes,
|
||||
content,
|
||||
extensions,
|
||||
options,
|
||||
}: {
|
||||
renderer: (options: TiptapStaticRendererOptions<T, Mark, Node>) => (ctx: { content: Node }) => T
|
||||
domOutputSpecToElement: DomOutputSpecToElement<T>
|
||||
mapDefinedTypes: {
|
||||
doc: (props: NodeProps<Node, T | T[]>) => T
|
||||
text: (props: NodeProps<Node, T | T[]>) => T
|
||||
}
|
||||
content: Node | JSONContent
|
||||
extensions: Extensions
|
||||
options?: Partial<TiptapStaticRendererOptions<T, Mark, Node>>
|
||||
}): T {
|
||||
// get all extensions in order & split them into nodes and marks
|
||||
extensions = resolveExtensions(extensions)
|
||||
const extensionAttributes = getAttributesFromExtensions(extensions)
|
||||
const { nodeExtensions, markExtensions } = splitExtensions(extensions)
|
||||
|
||||
if (!(content instanceof Node)) {
|
||||
content = Node.fromJSON(getSchemaByResolvedExtensions(extensions), content)
|
||||
}
|
||||
|
||||
return renderer({
|
||||
...options,
|
||||
nodeMapping: {
|
||||
...Object.fromEntries(
|
||||
nodeExtensions
|
||||
.filter(e => {
|
||||
if (e.name in mapDefinedTypes) {
|
||||
// These are predefined types that we don't need to map
|
||||
return false
|
||||
}
|
||||
// No need to generate mappings for nodes that are already mapped
|
||||
if (options?.nodeMapping) {
|
||||
return !(e.name in options.nodeMapping)
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(nodeExtension =>
|
||||
mapNodeExtensionToReactNode<T>(domOutputSpecToElement, nodeExtension, extensionAttributes, options),
|
||||
),
|
||||
),
|
||||
...mapDefinedTypes,
|
||||
...options?.nodeMapping,
|
||||
},
|
||||
markMapping: {
|
||||
...Object.fromEntries(
|
||||
markExtensions
|
||||
.filter(e => {
|
||||
// No need to generate mappings for marks that are already mapped
|
||||
if (options?.markMapping) {
|
||||
return !(e.name in options.markMapping)
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(mark => mapMarkExtensionToReactNode<T>(domOutputSpecToElement, mark, extensionAttributes, options)),
|
||||
),
|
||||
...options?.markMapping,
|
||||
},
|
||||
})({ content })
|
||||
}
|
105
packages/static-renderer/src/pm/html-string/html-string.ts
Normal file
105
packages/static-renderer/src/pm/html-string/html-string.ts
Normal file
@ -0,0 +1,105 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { DOMOutputSpecArray, Extensions, JSONContent } from '@tiptap/core'
|
||||
import type { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model'
|
||||
|
||||
import {
|
||||
renderJSONContentToString,
|
||||
serializeAttrsToHTMLString,
|
||||
serializeChildrenToHTMLString,
|
||||
} from '../../json/html-string/string.js'
|
||||
import { TiptapStaticRendererOptions } from '../../json/renderer.js'
|
||||
import { renderToElement } from '../extensionRenderer.js'
|
||||
|
||||
export { serializeAttrsToHTMLString, serializeChildrenToHTMLString } from '../../json/html-string/string.js'
|
||||
|
||||
/**
|
||||
* Take a DOMOutputSpec and return a function that can render it to a string
|
||||
* @param content The DOMOutputSpec to convert to a string
|
||||
* @returns A function that can render the DOMOutputSpec to a string
|
||||
*/
|
||||
export function domOutputSpecToHTMLString(content: DOMOutputSpec): (children?: string | string[]) => string {
|
||||
if (typeof content === 'string') {
|
||||
return () => content
|
||||
}
|
||||
if (typeof content === 'object' && 'length' in content) {
|
||||
const [_tag, attrs, children, ...rest] = content as DOMOutputSpecArray
|
||||
let tag = _tag
|
||||
const parts = tag.split(' ')
|
||||
|
||||
if (parts.length > 1) {
|
||||
tag = `${parts[1]} xmlns="${parts[0]}"`
|
||||
}
|
||||
|
||||
if (attrs === undefined) {
|
||||
return () => `<${tag}/>`
|
||||
}
|
||||
if (attrs === 0) {
|
||||
return child => `<${tag}>${serializeChildrenToHTMLString(child)}</${tag}>`
|
||||
}
|
||||
if (typeof attrs === 'object') {
|
||||
if (Array.isArray(attrs)) {
|
||||
if (children === undefined) {
|
||||
return child => `<${tag}>${domOutputSpecToHTMLString(attrs as DOMOutputSpecArray)(child)}</${tag}>`
|
||||
}
|
||||
if (children === 0) {
|
||||
return child => `<${tag}>${domOutputSpecToHTMLString(attrs as DOMOutputSpecArray)(child)}</${tag}>`
|
||||
}
|
||||
return child =>
|
||||
`<${tag}>${domOutputSpecToHTMLString(attrs as DOMOutputSpecArray)(child)}${[children]
|
||||
.concat(rest)
|
||||
.map(a => domOutputSpecToHTMLString(a)(child))}</${tag}>`
|
||||
}
|
||||
if (children === undefined) {
|
||||
return () => `<${tag}${serializeAttrsToHTMLString(attrs)}/>`
|
||||
}
|
||||
if (children === 0) {
|
||||
return child => `<${tag}${serializeAttrsToHTMLString(attrs)}>${serializeChildrenToHTMLString(child)}</${tag}>`
|
||||
}
|
||||
|
||||
return child =>
|
||||
`<${tag}${serializeAttrsToHTMLString(attrs)}>${[children]
|
||||
.concat(rest)
|
||||
.map(a => domOutputSpecToHTMLString(a)(child))
|
||||
.join('')}</${tag}>`
|
||||
}
|
||||
}
|
||||
|
||||
// TODO support DOM elements? How to handle them?
|
||||
throw new Error(
|
||||
'[tiptap error]: Unsupported DomOutputSpec type, check the `renderHTML` method output or implement a node mapping',
|
||||
{
|
||||
cause: content,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will statically render a Prosemirror Node to HTML using the provided extensions and options
|
||||
* @param content The content to render to HTML
|
||||
* @param extensions The extensions to use for rendering
|
||||
* @param options The options to use for rendering
|
||||
* @returns The rendered HTML string
|
||||
*/
|
||||
export function renderToHTMLString({
|
||||
content,
|
||||
extensions,
|
||||
options,
|
||||
}: {
|
||||
content: Node | JSONContent
|
||||
extensions: Extensions
|
||||
options?: Partial<TiptapStaticRendererOptions<string, Mark, Node>>
|
||||
}): string {
|
||||
return renderToElement<string>({
|
||||
renderer: renderJSONContentToString,
|
||||
domOutputSpecToElement: domOutputSpecToHTMLString,
|
||||
mapDefinedTypes: {
|
||||
// Map a doc node to concatenated children
|
||||
doc: ({ children }) => serializeChildrenToHTMLString(children),
|
||||
// Map a text node to its text content
|
||||
text: ({ node }) => node.text ?? '',
|
||||
},
|
||||
content,
|
||||
extensions,
|
||||
options,
|
||||
})
|
||||
}
|
2
packages/static-renderer/src/pm/html-string/index.ts
Normal file
2
packages/static-renderer/src/pm/html-string/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from '../extensionRenderer.js'
|
||||
export * from './html-string.js'
|
2
packages/static-renderer/src/pm/markdown/index.ts
Normal file
2
packages/static-renderer/src/pm/markdown/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from '../extensionRenderer.js'
|
||||
export * from './markdown.js'
|
142
packages/static-renderer/src/pm/markdown/markdown.ts
Normal file
142
packages/static-renderer/src/pm/markdown/markdown.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { Extensions, JSONContent } from '@tiptap/core'
|
||||
import type { Mark, Node } from '@tiptap/pm/model'
|
||||
|
||||
import { TiptapStaticRendererOptions } from '../../json/renderer.js'
|
||||
import { renderToHTMLString, serializeChildrenToHTMLString } from '../html-string/html-string.js'
|
||||
|
||||
/**
|
||||
* This code is just to show the flexibility of this renderer. We can potentially render content to any format we want.
|
||||
* This is a simple example of how we can render content to markdown. This is not a full implementation of a markdown renderer.
|
||||
*/
|
||||
export function renderToMarkdown({
|
||||
content,
|
||||
extensions,
|
||||
options,
|
||||
}: {
|
||||
content: Node | JSONContent
|
||||
extensions: Extensions
|
||||
options?: Partial<TiptapStaticRendererOptions<string, Mark, Node>>
|
||||
}) {
|
||||
return renderToHTMLString({
|
||||
content,
|
||||
extensions,
|
||||
options: {
|
||||
nodeMapping: {
|
||||
bulletList({ children }) {
|
||||
return `\n${serializeChildrenToHTMLString(children)}`
|
||||
},
|
||||
orderedList({ children }) {
|
||||
return `\n${serializeChildrenToHTMLString(children)}`
|
||||
},
|
||||
listItem({ node, children, parent }) {
|
||||
if (parent?.type.name === 'bulletList') {
|
||||
return `- ${serializeChildrenToHTMLString(children).trim()}\n`
|
||||
}
|
||||
if (parent?.type.name === 'orderedList') {
|
||||
let number = parent.attrs.start || 1
|
||||
|
||||
parent.forEach((parentChild, _offset, index) => {
|
||||
if (node === parentChild) {
|
||||
number = index + 1
|
||||
}
|
||||
})
|
||||
|
||||
return `${number}. ${serializeChildrenToHTMLString(children).trim()}\n`
|
||||
}
|
||||
|
||||
return serializeChildrenToHTMLString(children)
|
||||
},
|
||||
paragraph({ children }) {
|
||||
return `\n${serializeChildrenToHTMLString(children)}\n`
|
||||
},
|
||||
heading({ node, children }) {
|
||||
const level = node.attrs.level as number
|
||||
|
||||
return `${new Array(level).fill('#').join('')} ${children}\n`
|
||||
},
|
||||
codeBlock({ node, children }) {
|
||||
return `\n\`\`\`${node.attrs.language}\n${serializeChildrenToHTMLString(children)}\n\`\`\`\n`
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return `\n${serializeChildrenToHTMLString(children)
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(a => `> ${a}`)
|
||||
.join('\n')}`
|
||||
},
|
||||
image({ node }) {
|
||||
return ``
|
||||
},
|
||||
hardBreak() {
|
||||
return '\n'
|
||||
},
|
||||
horizontalRule() {
|
||||
return '\n---\n'
|
||||
},
|
||||
table({ children, node }) {
|
||||
if (!Array.isArray(children)) {
|
||||
return `\n${serializeChildrenToHTMLString(children)}\n`
|
||||
}
|
||||
|
||||
return `\n${serializeChildrenToHTMLString(children[0])}| ${new Array(node.childCount - 2).fill('---').join(' | ')} |\n${serializeChildrenToHTMLString(children.slice(1))}\n`
|
||||
},
|
||||
tableRow({ children }) {
|
||||
if (Array.isArray(children)) {
|
||||
return `| ${children.join(' | ')} |\n`
|
||||
}
|
||||
return `${serializeChildrenToHTMLString(children)}\n`
|
||||
},
|
||||
tableHeader({ children }) {
|
||||
return serializeChildrenToHTMLString(children).trim()
|
||||
},
|
||||
tableCell({ children }) {
|
||||
return serializeChildrenToHTMLString(children).trim()
|
||||
},
|
||||
...options?.nodeMapping,
|
||||
},
|
||||
markMapping: {
|
||||
bold({ children }) {
|
||||
return `**${serializeChildrenToHTMLString(children)}**`
|
||||
},
|
||||
italic({ children, node }) {
|
||||
let isBoldToo = false
|
||||
|
||||
// Check if the node being wrapped also has a bold mark, if so, we need to use the bold markdown syntax
|
||||
if (node?.marks.some(m => m.type.name === 'bold')) {
|
||||
isBoldToo = true
|
||||
}
|
||||
|
||||
if (isBoldToo) {
|
||||
// If the content is bold, just wrap the bold content in italic markdown syntax with another set of asterisks
|
||||
return `*${serializeChildrenToHTMLString(children)}*`
|
||||
}
|
||||
|
||||
return `_${serializeChildrenToHTMLString(children)}_`
|
||||
},
|
||||
code({ children }) {
|
||||
return `\`${serializeChildrenToHTMLString(children)}\``
|
||||
},
|
||||
strike({ children }) {
|
||||
return `~~${serializeChildrenToHTMLString(children)}~~`
|
||||
},
|
||||
underline({ children }) {
|
||||
return `<u>${serializeChildrenToHTMLString(children)}</u>`
|
||||
},
|
||||
subscript({ children }) {
|
||||
return `<sub>${serializeChildrenToHTMLString(children)}</sub>`
|
||||
},
|
||||
superscript({ children }) {
|
||||
return `<sup>${serializeChildrenToHTMLString(children)}</sup>`
|
||||
},
|
||||
link({ node, children }) {
|
||||
return `[${serializeChildrenToHTMLString(children)}](${node.attrs.href})`
|
||||
},
|
||||
highlight({ children }) {
|
||||
return `==${serializeChildrenToHTMLString(children)}==`
|
||||
},
|
||||
...options?.markMapping,
|
||||
},
|
||||
...options,
|
||||
},
|
||||
})
|
||||
}
|
2
packages/static-renderer/src/pm/react/index.ts
Normal file
2
packages/static-renderer/src/pm/react/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from '../extensionRenderer.js'
|
||||
export * from './react.js'
|
152
packages/static-renderer/src/pm/react/react.tsx
Normal file
152
packages/static-renderer/src/pm/react/react.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
/* eslint-disable no-plusplus, @typescript-eslint/no-explicit-any */
|
||||
import type { DOMOutputSpecArray, Extensions, JSONContent } from '@tiptap/core'
|
||||
import type { DOMOutputSpec, Mark, Node } from '@tiptap/pm/model'
|
||||
import React from 'react'
|
||||
|
||||
import { renderJSONContentToReactElement } from '../../json/react/react.js'
|
||||
import { TiptapStaticRendererOptions } from '../../json/renderer.js'
|
||||
import { renderToElement } from '../extensionRenderer.js'
|
||||
|
||||
/**
|
||||
* This function maps the attributes of a node or mark to HTML attributes
|
||||
* @param attrs The attributes to map
|
||||
* @param key The key to use for the React element
|
||||
* @returns The mapped HTML attributes as an object
|
||||
*/
|
||||
function mapAttrsToHTMLAttributes(attrs?: Record<string, any>, key?: string): Record<string, any> {
|
||||
if (!attrs) {
|
||||
return { key }
|
||||
}
|
||||
return Object.entries(attrs).reduce(
|
||||
(acc, [name, value]) => {
|
||||
if (name === 'class') {
|
||||
return Object.assign(acc, { className: value })
|
||||
}
|
||||
return Object.assign(acc, { [name]: value })
|
||||
},
|
||||
{ key },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a DOMOutputSpec and return a function that can render it to a React element
|
||||
* @param content The DOMOutputSpec to convert to a React element
|
||||
* @returns A function that can render the DOMOutputSpec to a React element
|
||||
*/
|
||||
export function domOutputSpecToReactElement(
|
||||
content: DOMOutputSpec,
|
||||
key = 0,
|
||||
): (children?: React.ReactNode) => React.ReactNode {
|
||||
if (typeof content === 'string') {
|
||||
return () => content
|
||||
}
|
||||
if (typeof content === 'object' && 'length' in content) {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [tag, attrs, children, ...rest] = content as DOMOutputSpecArray
|
||||
const parts = tag.split(' ')
|
||||
|
||||
if (parts.length > 1) {
|
||||
tag = parts[1]
|
||||
if (attrs === undefined) {
|
||||
attrs = {
|
||||
xmlns: parts[0],
|
||||
}
|
||||
}
|
||||
if (attrs === 0) {
|
||||
attrs = {
|
||||
xmlns: parts[0],
|
||||
}
|
||||
children = 0
|
||||
}
|
||||
if (typeof attrs === 'object') {
|
||||
attrs = Object.assign(attrs, { xmlns: parts[0] })
|
||||
}
|
||||
}
|
||||
|
||||
if (attrs === undefined) {
|
||||
return () => React.createElement(tag, mapAttrsToHTMLAttributes(undefined, key.toString()))
|
||||
}
|
||||
if (attrs === 0) {
|
||||
return child => React.createElement(tag, mapAttrsToHTMLAttributes(undefined, key.toString()), child)
|
||||
}
|
||||
if (typeof attrs === 'object') {
|
||||
if (Array.isArray(attrs)) {
|
||||
if (children === undefined) {
|
||||
return child =>
|
||||
React.createElement(
|
||||
tag,
|
||||
mapAttrsToHTMLAttributes(undefined, key.toString()),
|
||||
domOutputSpecToReactElement(attrs as DOMOutputSpecArray, key++)(child),
|
||||
)
|
||||
}
|
||||
if (children === 0) {
|
||||
return child =>
|
||||
React.createElement(
|
||||
tag,
|
||||
mapAttrsToHTMLAttributes(undefined, key.toString()),
|
||||
domOutputSpecToReactElement(attrs as DOMOutputSpecArray, key++)(child),
|
||||
)
|
||||
}
|
||||
return child =>
|
||||
React.createElement(
|
||||
tag,
|
||||
mapAttrsToHTMLAttributes(undefined, key.toString()),
|
||||
domOutputSpecToReactElement(attrs as DOMOutputSpecArray)(child),
|
||||
[children].concat(rest).map(outputSpec => domOutputSpecToReactElement(outputSpec, key++)(child)),
|
||||
)
|
||||
}
|
||||
if (children === undefined) {
|
||||
return () => React.createElement(tag, mapAttrsToHTMLAttributes(attrs, key.toString()))
|
||||
}
|
||||
if (children === 0) {
|
||||
return child => React.createElement(tag, mapAttrsToHTMLAttributes(attrs, key.toString()), child)
|
||||
}
|
||||
|
||||
return child =>
|
||||
React.createElement(
|
||||
tag,
|
||||
mapAttrsToHTMLAttributes(attrs, key.toString()),
|
||||
[children].concat(rest).map(outputSpec => domOutputSpecToReactElement(outputSpec, key++)(child)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO support DOM elements? How to handle them?
|
||||
throw new Error(
|
||||
'[tiptap error]: Unsupported DomOutputSpec type, check the `renderHTML` method output or implement a node mapping',
|
||||
{
|
||||
cause: content,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will statically render a Prosemirror Node to a React component using the given extensions
|
||||
* @param content The content to render to a React component
|
||||
* @param extensions The extensions to use for rendering
|
||||
* @param options The options to use for rendering
|
||||
* @returns The React element that represents the rendered content
|
||||
*/
|
||||
export function renderToReactElement({
|
||||
content,
|
||||
extensions,
|
||||
options,
|
||||
}: {
|
||||
content: Node | JSONContent
|
||||
extensions: Extensions
|
||||
options?: Partial<TiptapStaticRendererOptions<React.ReactNode, Mark, Node>>
|
||||
}): React.ReactNode {
|
||||
return renderToElement<React.ReactNode>({
|
||||
renderer: renderJSONContentToReactElement,
|
||||
domOutputSpecToElement: domOutputSpecToReactElement,
|
||||
mapDefinedTypes: {
|
||||
// Map a doc node to concatenated children
|
||||
doc: ({ children }) => <>{children}</>,
|
||||
// Map a text node to its text content
|
||||
text: ({ node }) => node.text ?? '',
|
||||
},
|
||||
content,
|
||||
extensions,
|
||||
options,
|
||||
})
|
||||
}
|
21
packages/static-renderer/tsup.config.ts
Normal file
21
packages/static-renderer/tsup.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig(
|
||||
[
|
||||
'src/json/html-string/index.ts',
|
||||
'src/json/react/index.ts',
|
||||
'src/json/renderer.ts',
|
||||
'src/pm/react/index.ts',
|
||||
'src/pm/html-string/index.ts',
|
||||
'src/pm/markdown/index.ts',
|
||||
'src/index.ts',
|
||||
].map(entry => ({
|
||||
entry: [entry],
|
||||
tsconfig: '../../tsconfig.build.json',
|
||||
outDir: `dist${entry.replace('src', '').split('/').slice(0, -1).join('/')}`,
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
format: ['esm', 'cjs'],
|
||||
external: [/^[^./]/],
|
||||
})),
|
||||
)
|
@ -808,6 +808,28 @@ importers:
|
||||
specifier: ^3.0.0-next.3
|
||||
version: link:../pm
|
||||
|
||||
packages/static-renderer:
|
||||
optionalDependencies:
|
||||
'@types/react':
|
||||
specifier: ^18.2.14
|
||||
version: 18.3.18
|
||||
'@types/react-dom':
|
||||
specifier: ^18.2.6
|
||||
version: 18.3.5(@types/react@18.3.18)
|
||||
react:
|
||||
specifier: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
devDependencies:
|
||||
'@tiptap/core':
|
||||
specifier: ^3.0.0-next.1
|
||||
version: link:../core
|
||||
'@tiptap/pm':
|
||||
specifier: ^3.0.0-next.1
|
||||
version: link:../pm
|
||||
|
||||
packages/suggestion:
|
||||
devDependencies:
|
||||
'@tiptap/core':
|
||||
|
@ -68,6 +68,7 @@ describe('extension table cell', () => {
|
||||
content,
|
||||
})
|
||||
|
||||
// @ts-expect-error content is not guaranteed to be this shape
|
||||
expect(editor.getJSON().content[0].content[0].content[0].attrs.colwidth[0]).to.eq(200)
|
||||
|
||||
editor?.destroy()
|
||||
@ -94,6 +95,7 @@ describe('extension table cell', () => {
|
||||
content,
|
||||
})
|
||||
|
||||
// @ts-expect-error content is not guaranteed to be this shape
|
||||
expect(editor.getJSON().content[0].content[0].content[1].attrs.colwidth).deep.equal([150, 100])
|
||||
|
||||
editor?.destroy()
|
||||
|
@ -68,6 +68,7 @@ describe('extension table header', () => {
|
||||
content,
|
||||
})
|
||||
|
||||
// @ts-expect-error content is not guaranteed to be this shape
|
||||
expect(editor.getJSON().content[0].content[0].content[0].attrs.colwidth[0]).to.eq(200)
|
||||
|
||||
editor?.destroy()
|
||||
@ -94,6 +95,7 @@ describe('extension table header', () => {
|
||||
content,
|
||||
})
|
||||
|
||||
// @ts-expect-error content is not guaranteed to be this shape
|
||||
expect(editor.getJSON().content[0].content[0].content[1].attrs.colwidth).deep.equal([150, 100])
|
||||
|
||||
editor?.destroy()
|
||||
|
296
tests/cypress/integration/static-renderer/json-string.spec.ts
Normal file
296
tests/cypress/integration/static-renderer/json-string.spec.ts
Normal file
@ -0,0 +1,296 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
import { TextType } from '@tiptap/core'
|
||||
import Bold from '@tiptap/extension-bold'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { Mark, Node } from '@tiptap/pm/model'
|
||||
import { renderJSONContentToString, serializeChildrenToHTMLString } from '@tiptap/static-renderer/json/html-string'
|
||||
import { renderToHTMLString } from '@tiptap/static-renderer/pm/html-string'
|
||||
|
||||
describe('static render json to string (no prosemirror)', () => {
|
||||
it('generate an HTML string from JSON without an editor instance', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
attrs: {},
|
||||
}
|
||||
|
||||
const html = renderJSONContentToString({
|
||||
nodeMapping: {
|
||||
doc: ({ children }) => {
|
||||
return `<doc>${serializeChildrenToHTMLString(children)}</doc>`
|
||||
},
|
||||
paragraph: ({ children }) => {
|
||||
return `<p>${serializeChildrenToHTMLString(children)}</p>`
|
||||
},
|
||||
text: ({ node }) => {
|
||||
return (node as unknown as TextType).text
|
||||
},
|
||||
},
|
||||
markMapping: {},
|
||||
})({ content: json })
|
||||
|
||||
expect(html).to.eq('<doc><p>Example Text</p></doc>')
|
||||
})
|
||||
|
||||
it('supports mapping nodes & marks', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
attrs: {},
|
||||
}
|
||||
|
||||
const html = renderJSONContentToString({
|
||||
nodeMapping: {
|
||||
doc: ({ children }) => {
|
||||
return `<doc>${serializeChildrenToHTMLString(children)}</doc>`
|
||||
},
|
||||
paragraph: ({ children }) => {
|
||||
return `<p>${serializeChildrenToHTMLString(children)}</p>`
|
||||
},
|
||||
text: ({ node }) => {
|
||||
return (node as unknown as TextType).text
|
||||
},
|
||||
},
|
||||
markMapping: {
|
||||
bold: ({ children }) => {
|
||||
return `<strong>${serializeChildrenToHTMLString(children)}</strong>`
|
||||
},
|
||||
},
|
||||
})({ content: json })
|
||||
|
||||
expect(html).to.eq('<doc><p><strong>Example Text</strong></p></doc>')
|
||||
})
|
||||
|
||||
it('gives access to the original JSON node or mark', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: {
|
||||
level: 2,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
attrs: {},
|
||||
}
|
||||
|
||||
const html = renderJSONContentToString({
|
||||
nodeMapping: {
|
||||
doc: ({ node, children }) => {
|
||||
expect(node).to.deep.eq(json)
|
||||
return `<doc>${serializeChildrenToHTMLString(children)}</doc>`
|
||||
},
|
||||
heading: ({ node, children }) => {
|
||||
expect(node).to.deep.eq({
|
||||
type: 'heading',
|
||||
attrs: {
|
||||
level: 2,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
return `<h${node.attrs.level}>${serializeChildrenToHTMLString(children)}</h${node.attrs.level}>`
|
||||
},
|
||||
text: ({ node }) => {
|
||||
expect(node).to.deep.eq({
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
})
|
||||
return (node as unknown as TextType).text
|
||||
},
|
||||
},
|
||||
markMapping: {
|
||||
bold: ({ children, mark }) => {
|
||||
expect(mark).to.deep.eq({
|
||||
type: 'bold',
|
||||
attrs: {},
|
||||
})
|
||||
return `<strong>${serializeChildrenToHTMLString(children)}</strong>`
|
||||
},
|
||||
},
|
||||
})({ content: json })
|
||||
|
||||
expect(html).to.eq('<doc><h2><strong>Example Text</strong></h2></doc>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('static render json to string (with prosemirror)', () => {
|
||||
it('generates an HTML string from JSON without an editor instance', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
attrs: {},
|
||||
}
|
||||
|
||||
const html = renderToHTMLString({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, Bold],
|
||||
})
|
||||
|
||||
expect(html).to.eq('<p><strong>Example Text</strong></p>')
|
||||
})
|
||||
|
||||
it('supports custom mapping for nodes & marks', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
attrs: {},
|
||||
}
|
||||
|
||||
const html = renderToHTMLString({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, Bold],
|
||||
options: {
|
||||
nodeMapping: {
|
||||
doc: ({ children }) => {
|
||||
return `<doc>${serializeChildrenToHTMLString(children)}</doc>`
|
||||
},
|
||||
},
|
||||
markMapping: {
|
||||
bold: ({ children }) => {
|
||||
return `<b>${serializeChildrenToHTMLString(children)}</b>`
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(html).to.eq('<doc><p><b>Example Text</b></p></doc>')
|
||||
})
|
||||
|
||||
it('gives access to a prosemirror node or mark instance', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Example Text',
|
||||
marks: [
|
||||
{
|
||||
type: 'bold',
|
||||
attrs: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
attrs: {},
|
||||
}
|
||||
|
||||
const html = renderToHTMLString({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, Bold],
|
||||
options: {
|
||||
nodeMapping: {
|
||||
doc: ({ children, node }) => {
|
||||
expect(node.type.name).to.eq('doc')
|
||||
expect(node).to.be.instanceOf(Node)
|
||||
return `<doc>${serializeChildrenToHTMLString(children)}</doc>`
|
||||
},
|
||||
},
|
||||
markMapping: {
|
||||
bold: ({ children, mark }) => {
|
||||
expect(mark.type.name).to.eq('bold')
|
||||
expect(mark).to.be.instanceOf(Mark)
|
||||
return `<b>${serializeChildrenToHTMLString(children)}</b>`
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(html).to.eq('<doc><p><b>Example Text</b></p></doc>')
|
||||
})
|
||||
})
|
@ -30,6 +30,16 @@ module.exports = on => {
|
||||
.forEach(name => {
|
||||
alias[`@tiptap/pm${name.split('/').slice(0, -1).join('/')}$`] = path.resolve(`../packages/pm/${name}/index.ts`)
|
||||
})
|
||||
// Specifically resolve the static-renderer package
|
||||
alias['@tiptap/static-renderer/json/html-string$'] = path.resolve(
|
||||
'../packages/static-renderer/src/json/html-string/index.ts',
|
||||
)
|
||||
alias['@tiptap/static-renderer/pm/html-string$'] = path.resolve(
|
||||
'../packages/static-renderer/src/pm/html-string/index.ts',
|
||||
)
|
||||
alias['@tiptap/static-renderer/pm/react$'] = path.resolve('../packages/static-renderer/src/pm/react/index.ts')
|
||||
alias['@tiptap/static-renderer/pm/markdown$'] = path.resolve('../packages/static-renderer/src/pm/markdown/index.ts')
|
||||
alias['@tiptap/static-renderer$'] = path.resolve('../packages/static-renderer/src/index.ts')
|
||||
|
||||
const options = {
|
||||
webpackOptions: {
|
||||
|
@ -1,11 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "bundler",
|
||||
"strict": false,
|
||||
"noEmit": false,
|
||||
"sourceMap": false,
|
||||
"types": ["cypress", "react", "react-dom"],
|
||||
"paths": {
|
||||
"@tiptap/static-renderer/pm/*": ["packages/static-renderer/src/pm/*"],
|
||||
"@tiptap/static-renderer/json/*": ["packages/static-renderer/src/json/*"],
|
||||
"@tiptap/*": ["packages/*/src", "packages/*/dist"],
|
||||
"@tiptap/pm/*": ["packages/pm/*"]
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user