Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(static-renderer): add @tiptap/static-renderer to enable static rendering of content #5528

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

105 changes: 19 additions & 86 deletions packages/core/src/ExtensionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@ 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 { Mark, NodeConfig } from './index.js'
import {
flattenExtensions, getAttributesFromExtensions,
getExtensionField,
getNodeType,
getRenderedAttributes,
getSchemaByResolvedExtensions,
getSchemaTypeByName,
isExtensionRulesEnabled,
resolveExtensions,
sortExtensions,
splitExtensions,
} from './helpers/index.js'
import { 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
Expand All @@ -30,87 +34,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))

if (duplicatedNames.length) {
console.warn(
`[tiptap warn]: Duplicate extension names found: [${duplicatedNames
.map(item => `'${item}'`)
.join(', ')}]. This can lead to issues.`,
)
}

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
}
static resolve = resolveExtensions

if (priorityA < priorityB) {
return 1
}
static sort = sortExtensions

return 0
})
}
static flatten = flattenExtensions

/**
* Get all commands from the extensions.
Expand Down Expand Up @@ -155,7 +88,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[] = []
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/helpers/flattenExtensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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)
)
}
4 changes: 2 additions & 2 deletions packages/core/src/helpers/getSchema.ts
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 3 additions & 0 deletions packages/core/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -45,6 +46,8 @@ 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 './selectionToInsertionEnd.js'
export * from './sortExtensions.js'
export * from './splitExtensions.js'
25 changes: 25 additions & 0 deletions packages/core/src/helpers/resolveExtensions.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 26 additions & 0 deletions packages/core/src/helpers/sortExtensions.ts
Original file line number Diff line number Diff line change
@@ -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
})
}
5 changes: 4 additions & 1 deletion packages/core/src/utilities/findDuplicates.ts
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
19 changes: 10 additions & 9 deletions packages/react/src/NodeViewContent.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
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'> = {
// eslint-disable-next-line no-undef
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', ...props }: NodeViewContentProps<T>) {
const { nodeViewContentRef, nodeViewContentChildren } = useReactNodeView()

return (
// @ts-ignore
Expand All @@ -21,6 +20,8 @@ export const NodeViewContent: React.FC<NodeViewContentProps> = props => {
whiteSpace: 'pre-wrap',
...props.style,
}}
/>
>
{nodeViewContentChildren}
</Tag>
)
}
17 changes: 14 additions & 3 deletions packages/react/src/useReactNodeView.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { createContext, useContext } from 'react'
import { createContext, ReactNode, useContext } from 'react'

export interface ReactNodeViewContextProps {
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 useReactNodeView = () => useContext(ReactNodeViewContext)
1 change: 1 addition & 0 deletions packages/static-renderer/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Change Log
18 changes: 18 additions & 0 deletions packages/static-renderer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# @tiptap/static-renderer

[![Version](https://img.shields.io/npm/v/@tiptap/static-renderer.svg?label=version)](https://www.npmjs.com/package/@tiptap/static-renderer)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/static-renderer.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/static-renderer.svg)](https://www.npmjs.com/package/@tiptap/static-renderer)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](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).
Loading
Loading