snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

3184
packages/react/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

18
packages/react/README.md Normal file
View File

@@ -0,0 +1,18 @@
# @tiptap/react
[![Version](https://img.shields.io/npm/v/@tiptap/react.svg?label=version)](https://www.npmjs.com/package/@tiptap/react)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/react.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/react.svg)](https://www.npmjs.com/package/@tiptap/react)
[![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).

View File

@@ -0,0 +1,78 @@
{
"name": "@tiptap/react",
"version": "3.30.3",
"description": "React components for tiptap",
"keywords": [
"tiptap",
"tiptap react components"
],
"homepage": "https://tiptap.dev",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/react"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"sideEffects": false,
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./menus": {
"types": {
"import": "./dist/menus/index.d.ts",
"require": "./dist/menus/index.d.cts"
},
"import": "./dist/menus/index.js",
"require": "./dist/menus/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"devDependencies": {
"@tiptap/core": "workspace:^",
"@tiptap/pm": "workspace:^",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"peerDependencies": {
"@tiptap/core": "workspace:*",
"@tiptap/pm": "workspace:*",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "workspace:^",
"@tiptap/extension-floating-menu": "workspace:^"
}
}

View File

@@ -0,0 +1,62 @@
import type { Editor } from '@tiptap/core'
import type { HTMLAttributes, ReactNode } from 'react'
import React, { createContext, useContext, useMemo } from 'react'
import { EditorContent } from './EditorContent.js'
import type { UseEditorOptions } from './useEditor.js'
import { useEditor } from './useEditor.js'
export type EditorContextValue = {
editor: Editor | null
}
export const EditorContext = createContext<EditorContextValue>({
editor: null,
})
export const EditorConsumer = EditorContext.Consumer
/**
* A hook to get the current editor instance.
*/
export const useCurrentEditor = () => useContext(EditorContext)
export type EditorProviderProps = {
children?: ReactNode
slotBefore?: ReactNode
slotAfter?: ReactNode
editorContainerProps?: HTMLAttributes<HTMLDivElement>
} & UseEditorOptions
/**
* This is the provider component for the editor.
* It allows the editor to be accessible across the entire component tree
* with `useCurrentEditor`.
*/
export function EditorProvider({
children,
slotAfter,
slotBefore,
editorContainerProps = {},
...editorOptions
}: EditorProviderProps) {
const editor = useEditor(editorOptions)
const contextValue = useMemo(() => ({ editor }), [editor])
if (!editor) {
return null
}
return (
<EditorContext.Provider value={contextValue}>
{slotBefore}
<EditorConsumer>
{({ editor: currentEditor }) => (
<EditorContent editor={currentEditor} {...editorContainerProps} />
)}
</EditorConsumer>
{children}
{slotAfter}
</EditorContext.Provider>
)
}

View File

@@ -0,0 +1,16 @@
import type { Editor } from '@tiptap/core'
import type { ReactPortal } from 'react'
import type { ReactRenderer } from './ReactRenderer.js'
export type EditorWithContentComponent = Editor & {
contentComponent?: ContentComponent | null
isEditorContentInitialized?: boolean
}
export type ContentComponent = {
setRenderer(id: string, renderer: ReactRenderer): void
removeRenderer(id: string): void
subscribe: (callback: () => void) => () => void
getSnapshot: () => Record<string, ReactPortal>
getServerSnapshot: () => Record<string, ReactPortal>
}

View File

@@ -0,0 +1,39 @@
import React from 'react'
import { describe, expect, it, vi } from 'vite-plus/test'
import { createContentComponent } from './EditorContent.js'
import type { ReactRenderer } from './ReactRenderer.js'
const createRenderer = (id: string) =>
({
reactElement: React.createElement('span', null, id),
element: document.createElement('div'),
}) as ReactRenderer
describe('createContentComponent', () => {
it('batches synchronous renderer change notifications', async () => {
const contentComponent = createContentComponent()
const subscriber = vi.fn()
contentComponent.subscribe(subscriber)
contentComponent.setRenderer('first', createRenderer('first'))
contentComponent.setRenderer('second', createRenderer('second'))
expect(Object.keys(contentComponent.getSnapshot())).toEqual(['first', 'second'])
expect(subscriber).not.toHaveBeenCalled()
await Promise.resolve()
expect(subscriber).toHaveBeenCalledTimes(1)
contentComponent.removeRenderer('first')
contentComponent.removeRenderer('second')
expect(Object.keys(contentComponent.getSnapshot())).toEqual([])
await Promise.resolve()
expect(subscriber).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,214 @@
import type { Editor } from '@tiptap/core'
import type { ForwardedRef, HTMLProps, LegacyRef, MutableRefObject } from 'react'
import React, { forwardRef } from 'react'
import ReactDOM from 'react-dom'
import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js'
import type { ContentComponent, EditorWithContentComponent } from './Editor.js'
import type { ReactRenderer } from './ReactRenderer.js'
const mergeRefs = <T extends HTMLDivElement>(
...refs: Array<MutableRefObject<T> | LegacyRef<T> | undefined>
) => {
return (node: T) => {
refs.forEach(ref => {
if (typeof ref === 'function') {
ref(node)
} else if (ref) {
;(ref as MutableRefObject<T | null>).current = node
}
})
}
}
/**
* This component renders all of the editor's node views.
*/
const Portals: React.FC<{ contentComponent: ContentComponent }> = ({ contentComponent }) => {
// For performance reasons, we render the node view portals on state changes only
const renderers = useSyncExternalStore(
contentComponent.subscribe,
contentComponent.getSnapshot,
contentComponent.getServerSnapshot,
)
// This allows us to directly render the portals without any additional wrapper
return <>{Object.values(renderers)}</>
}
export interface EditorContentProps extends HTMLProps<HTMLDivElement> {
editor: Editor | null
innerRef?: ForwardedRef<HTMLDivElement | null>
}
export function createContentComponent(): ContentComponent {
const subscribers = new Set<() => void>()
let renderers: Record<string, React.ReactPortal> = {}
let isNotificationQueued = false
const notifySubscribers = () => {
if (isNotificationQueued || !subscribers.size) {
return
}
isNotificationQueued = true
queueMicrotask(() => {
isNotificationQueued = false
subscribers.forEach(subscriber => subscriber())
})
}
return {
/**
* Subscribe to the editor instance's changes.
*/
subscribe(callback: () => void) {
subscribers.add(callback)
return () => {
subscribers.delete(callback)
}
},
getSnapshot() {
return renderers
},
getServerSnapshot() {
return renderers
},
/**
* Adds a new NodeView Renderer to the editor.
*/
setRenderer(id: string, renderer: ReactRenderer) {
renderers = {
...renderers,
[id]: ReactDOM.createPortal(renderer.reactElement, renderer.element, id),
}
notifySubscribers()
},
/**
* Removes a NodeView Renderer from the editor.
*/
removeRenderer(id: string) {
const nextRenderers = { ...renderers }
delete nextRenderers[id]
renderers = nextRenderers
notifySubscribers()
},
}
}
export class PureEditorContent extends React.Component<
EditorContentProps,
{ hasContentComponentInitialized: boolean }
> {
editorContentRef: React.RefObject<any>
constructor(props: EditorContentProps) {
super(props)
this.editorContentRef = React.createRef()
}
componentDidMount() {
this.init()
}
componentDidUpdate() {
this.init()
}
init() {
const editor = this.props.editor as EditorWithContentComponent | null
if (editor && !editor.isDestroyed && editor.view.dom?.parentNode) {
if (editor.contentComponent) {
return
}
const element = this.editorContentRef.current
element.append(...editor.view.dom.parentNode.childNodes)
editor.setOptions({
element,
})
editor.contentComponent = createContentComponent()
editor.createNodeViews()
editor.isEditorContentInitialized = true
this.forceUpdate()
}
}
componentWillUnmount() {
const editor = this.props.editor as EditorWithContentComponent | null
if (!editor) {
return
}
editor.isEditorContentInitialized = false
if (!editor.isDestroyed) {
editor.view.setProps({
nodeViews: {},
})
}
editor.contentComponent = null
// try to reset the editor element
// may fail if this editor's view.dom was never initialized/mounted yet
try {
if (!editor.view.dom?.parentNode) {
return
}
// TODO using the new editor.mount method might allow us to remove this
const newElement = document.createElement('div')
newElement.append(...editor.view.dom.parentNode.childNodes)
editor.setOptions({
element: newElement,
})
} catch {
// do nothing, nothing to reset
}
}
render() {
const { editor, innerRef, ...rest } = this.props
return (
<>
<div ref={mergeRefs(innerRef, this.editorContentRef)} {...rest} />
{/* @ts-ignore */}
{editor?.contentComponent && <Portals contentComponent={editor.contentComponent} />}
</>
)
}
}
// EditorContent should be re-created whenever the Editor instance changes
const EditorContentWithKey = forwardRef<HTMLDivElement, EditorContentProps>(
(props: Omit<EditorContentProps, 'innerRef'>, ref) => {
const key = React.useMemo(() => {
return Math.floor(Math.random() * 0xffffffff).toString()
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [props.editor])
// Can't use JSX here because it conflicts with the type definition of Vue's JSX, so use createElement
return React.createElement(PureEditorContent, {
key,
innerRef: ref,
...props,
})
},
)
export const EditorContent = React.memo(EditorContentWithKey)

View File

@@ -0,0 +1,30 @@
import type { ComponentProps } from 'react'
import React from 'react'
import { useReactNodeView } from './useReactNodeView.js'
export type NodeViewContentProps<T extends keyof React.JSX.IntrinsicElements = 'div'> = {
as?: NoInfer<T>
} & ComponentProps<T>
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
<Tag
{...props}
ref={nodeViewContentRef}
data-node-view-content=""
style={{
whiteSpace: 'pre-wrap',
...props.style,
}}
>
{nodeViewContentChildren}
</Tag>
)
}

View File

@@ -0,0 +1,27 @@
import React from 'react'
import { useReactNodeView } from './useReactNodeView.js'
export interface NodeViewWrapperProps {
[key: string]: any
as?: React.ElementType
}
export const NodeViewWrapper: React.FC<NodeViewWrapperProps> = React.forwardRef((props, ref) => {
const { onDragStart } = useReactNodeView()
const Tag = props.as || 'div'
return (
// @ts-ignore
<Tag
{...props}
ref={ref}
data-node-view-wrapper=""
onDragStart={onDragStart}
style={{
whiteSpace: 'normal',
...props.style,
}}
/>
)
})

View File

@@ -0,0 +1,114 @@
/* oslint-disableno-shadow */
import type { MarkViewProps, MarkViewRenderer, MarkViewRendererOptions } from '@tiptap/core'
import { MarkView } from '@tiptap/core'
import React from 'react'
// import { flushSync } from 'react-dom'
import { ReactRenderer } from './ReactRenderer.js'
export interface MarkViewContextProps {
markViewContentRef: (element: HTMLElement | null) => void
}
export const ReactMarkViewContext = React.createContext<MarkViewContextProps>({
markViewContentRef: () => {
// do nothing
},
})
export type MarkViewContentProps<T extends keyof React.JSX.IntrinsicElements = 'span'> = {
as?: T
} & Omit<React.ComponentProps<T>, 'as'>
export const MarkViewContent = <T extends keyof React.JSX.IntrinsicElements = 'span'>(
props: MarkViewContentProps<T>,
) => {
const { as: Tag = 'span', ...rest } = props
const { markViewContentRef } = React.useContext(ReactMarkViewContext)
return (
// @ts-ignore
<Tag {...rest} ref={markViewContentRef} data-mark-view-content="" />
)
}
export interface ReactMarkViewRendererOptions extends MarkViewRendererOptions {
/**
* The tag name of the element wrapping the React component.
*/
as?: string
className?: string
attrs?: { [key: string]: string }
}
export class ReactMarkView extends MarkView<
React.ComponentType<MarkViewProps>,
ReactMarkViewRendererOptions
> {
renderer: ReactRenderer
contentDOMElement: HTMLElement
constructor(
component: React.ComponentType<MarkViewProps>,
props: MarkViewProps,
options?: Partial<ReactMarkViewRendererOptions>,
) {
super(component, props, options)
const { as = 'span', attrs, className = '' } = options || {}
const componentProps = {
...props,
updateAttributes: this.updateAttributes.bind(this),
} satisfies MarkViewProps
this.contentDOMElement = document.createElement('span')
const markViewContentRef: MarkViewContextProps['markViewContentRef'] = el => {
if (el && !el.contains(this.contentDOMElement)) {
el.appendChild(this.contentDOMElement)
}
}
const context: MarkViewContextProps = {
markViewContentRef,
}
// For performance reasons, we memoize the provider component
// And all of the things it requires are declared outside of the component, so it doesn't need to re-render
const ReactMarkViewProvider: React.FunctionComponent<MarkViewProps> = React.memo(
componentProps => {
return (
<ReactMarkViewContext.Provider value={context}>
{React.createElement(component, componentProps)}
</ReactMarkViewContext.Provider>
)
},
)
ReactMarkViewProvider.displayName = 'ReactMarkView'
this.renderer = new ReactRenderer(ReactMarkViewProvider, {
editor: props.editor,
props: componentProps,
as,
className: `mark-${props.mark.type.name} ${className}`.trim(),
})
if (attrs) {
this.renderer.updateAttributes(attrs)
}
}
get dom() {
return this.renderer.element
}
get contentDOM() {
return this.contentDOMElement
}
}
export function ReactMarkViewRenderer(
component: React.ComponentType<MarkViewProps>,
options: Partial<ReactMarkViewRendererOptions> = {},
): MarkViewRenderer {
return props => new ReactMarkView(component, props, options)
}

View File

@@ -0,0 +1,566 @@
import { act, render } from '@testing-library/react'
import { Editor, Node } from '@tiptap/core'
import type { NodeViewRendererProps } from '@tiptap/core'
import type { NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import React, { useEffect, useState } from 'react'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { EditorContent } from './EditorContent.js'
import { NodeViewContent } from './NodeViewContent.js'
import { NodeViewWrapper } from './NodeViewWrapper.js'
import { ReactNodeViewRenderer } from './ReactNodeViewRenderer.js'
import type { ReactNodeViewProps } from './types.js'
const renderedPositions: Array<number | undefined> = []
const renderErrors: unknown[] = []
const bumpHandles = new Map<string, () => void>()
const explodingIds = new Set<string>()
const selectionStateRenderCounts = new Map<string, number>()
// Optional hook run inside the click handler before the replace commands.
let beforeReplace: (() => void) | undefined
const ContainerComponent = (props: ReactNodeViewProps) => {
const [, setTick] = useState(0)
const id = props.node.attrs.id as string
const renderKey = `${props.node.type.name}:${props.node.textContent}`
selectionStateRenderCounts.set(renderKey, (selectionStateRenderCounts.get(renderKey) ?? 0) + 1)
useEffect(() => {
bumpHandles.set(id, () => setTick(tick => tick + 1))
setTick(tick => tick + 1)
return () => {
bumpHandles.delete(id)
}
}, [id])
if (explodingIds.has(id)) {
throw new Error(`render bomb in container ${id}`)
}
// Calling getPos in the render path is what crashes with React 19.
try {
renderedPositions.push(props.getPos())
} catch (error) {
renderErrors.push(error)
throw error
}
const replaceSelf = () => {
beforeReplace?.()
// setState first so React has pending sync work when the
// transactions below run.
setTick(tick => tick + 1)
const pos = props.getPos()
if (typeof pos !== 'number') {
return
}
// The first transaction destroys this node view while its component is
// still mounted. The second one constructs a new node view, whose
// ReactRenderer flushSync then re-renders pending components while
// ProseMirror's view tree is mid-update.
props.editor.commands.deleteRange({ from: pos, to: pos + props.node.nodeSize })
props.editor.commands.insertContentAt(0, {
type: 'container',
attrs: { id: 'fresh' },
content: [{ type: 'item', content: [{ type: 'paragraph' }] }],
})
}
return React.createElement(
NodeViewWrapper,
{
'data-node-view-selected': String(props.selected),
'data-selection-inside': String(props.selectionInside),
},
React.createElement('button', {
type: 'button',
'data-testid': `replace-${id}`,
onClick: replaceSelf,
}),
React.createElement(NodeViewContent),
)
}
const ItemComponent = (props: ReactNodeViewProps) => {
const renderKey = `${props.node.type.name}:${props.node.textContent}`
selectionStateRenderCounts.set(renderKey, (selectionStateRenderCounts.get(renderKey) ?? 0) + 1)
return React.createElement(
NodeViewWrapper,
{
'data-node-view-selected': String(props.selected),
'data-selection-inside': String(props.selectionInside),
},
React.createElement(NodeViewContent),
)
}
const WidgetComponent = () => {
return React.createElement(NodeViewWrapper, null)
}
const ReactParagraphComponent = () => {
return React.createElement(NodeViewWrapper, null, React.createElement(NodeViewContent))
}
const ReactParagraph = Paragraph.extend({
addNodeView() {
return ReactNodeViewRenderer(ReactParagraphComponent)
},
})
const Container = Node.create({
name: 'container',
group: 'block',
content: 'item+',
addAttributes() {
return {
id: { default: null },
}
},
parseHTML() {
return [{ tag: 'div[data-type="container"]' }]
},
renderHTML() {
return ['div', { 'data-type': 'container' }, 0]
},
addNodeView() {
return ReactNodeViewRenderer(ContainerComponent)
},
})
const Item = Node.create({
name: 'item',
group: 'block',
content: 'paragraph+',
parseHTML() {
return [{ tag: 'div[data-type="item"]' }]
},
renderHTML() {
return ['div', { 'data-type': 'item' }, 0]
},
addNodeView() {
return ReactNodeViewRenderer(ItemComponent)
},
})
const ContainerWithTextSelection = Container.extend({
addNodeView() {
return ReactNodeViewRenderer(ContainerComponent, { selectedOnTextSelection: true })
},
})
const ItemWithTextSelection = Item.extend({
addNodeView() {
return ReactNodeViewRenderer(ItemComponent, { selectedOnTextSelection: true })
},
})
const Widget = Node.create({
name: 'widget',
group: 'block',
atom: true,
parseHTML() {
return [{ tag: 'div[data-type="widget"]' }]
},
renderHTML() {
return ['div', { 'data-type': 'widget' }]
},
addNodeView() {
return ReactNodeViewRenderer(WidgetComponent)
},
})
const createEditorWithContainers = ({ selectedOnTextSelection = false } = {}) => {
return new Editor({
extensions: [
Document,
Paragraph,
Text,
selectedOnTextSelection ? ContainerWithTextSelection : Container,
selectedOnTextSelection ? ItemWithTextSelection : Item,
],
content:
'<div data-type="container" id="a"><div data-type="item"><p>first</p></div></div>' +
'<div data-type="container" id="b"><div data-type="item"><p>second</p></div></div>',
})
}
const createEditorWithReactParagraph = () => {
return new Editor({
extensions: [Document, ReactParagraph, Text],
content: '<p>Hello</p>',
})
}
const createEditorWithWidget = () => {
return new Editor({
extensions: [Document, Paragraph, Text, Widget],
content: '<p>abc</p><div data-type="widget"></div>',
})
}
const flushMicrotasks = async () => {
await act(async () => {
await Promise.resolve()
})
}
const renderEditor = async (editor: Editor) => {
const result = render(React.createElement(EditorContent, { editor }))
await flushMicrotasks()
return result
}
const flushAnimationFrame = async () => {
await act(async () => {
await new Promise<void>(resolve => {
requestAnimationFrame(() => resolve())
})
})
}
const clickReplace = (id: string) => {
document
.querySelector(`[data-testid="replace-${id}"]`)!
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
}
const getSelectedStates = (container: HTMLElement) => {
return Array.from(container.querySelectorAll('[data-node-view-selected]'), element =>
element.getAttribute('data-node-view-selected'),
)
}
describe('ReactNodeViewRenderer', () => {
afterEach(() => {
renderedPositions.length = 0
renderErrors.length = 0
bumpHandles.clear()
explodingIds.clear()
selectionStateRenderCounts.clear()
beforeReplace = undefined
document.body.innerHTML = ''
})
it('returns a valid fallback node view before EditorContent initializes', () => {
let fallbackNodeView: ProseMirrorNodeView | undefined
const FallbackParagraph = Paragraph.extend({
addNodeView() {
const renderNodeView = ReactNodeViewRenderer(ReactParagraphComponent)
return (props: NodeViewRendererProps) => {
fallbackNodeView = renderNodeView(props)
return fallbackNodeView
}
},
})
let editor: Editor | undefined
expect(() => {
editor = new Editor({
extensions: [Document, FallbackParagraph, Text],
content: '<p>Hello</p>',
})
}).not.toThrow()
expect(fallbackNodeView?.dom).toBeInstanceOf(HTMLElement)
expect(fallbackNodeView?.contentDOM).toBeNull()
expect((fallbackNodeView?.update as (() => boolean) | undefined)?.()).toBe(false)
expect(() => editor?.destroy()).not.toThrow()
})
it('renders nested node views and resolves getPos during render', async () => {
const editor = createEditorWithContainers()
const { container } = await renderEditor(editor)
expect(container.querySelector('[data-node-view-wrapper]')).not.toBeNull()
expect(renderedPositions.length).toBeGreaterThan(0)
expect(renderedPositions).toContain(0)
editor.destroy()
})
it('only selects the node view targeted by ProseMirror', async () => {
const editor = createEditorWithContainers()
const { container } = await renderEditor(editor)
editor.commands.setNodeSelection(0)
await flushAnimationFrame()
expect(getSelectedStates(container)).toEqual(['true', 'false', 'false', 'false'])
editor.destroy()
})
it('marks only node views containing the text selection as selectionInside', async () => {
const editor = createEditorWithContainers()
const { container } = await renderEditor(editor)
editor.commands.setTextSelection(3)
await flushMicrotasks()
const selectionInsideStates = Array.from(
container.querySelectorAll('[data-selection-inside]'),
element => element.getAttribute('data-selection-inside'),
)
expect(selectionInsideStates).toEqual(['true', 'true', 'false', 'false'])
const renderCounts = new Map(selectionStateRenderCounts)
editor.commands.setTextSelection(4)
await flushMicrotasks()
expect(selectionStateRenderCounts).toEqual(renderCounts)
editor.destroy()
})
it('keeps selectedOnTextSelection without rerendering an unchanged node view path', async () => {
const editor = createEditorWithContainers({ selectedOnTextSelection: true })
const { container } = await renderEditor(editor)
editor.commands.selectAll()
await flushMicrotasks()
const initialRenderCounts = new Map(selectionStateRenderCounts)
editor.commands.setTextSelection(3)
await flushMicrotasks()
expect(getSelectedStates(container)).toEqual(['true', 'true', 'false', 'false'])
expect(selectionStateRenderCounts.get('container:first')).toBe(
initialRenderCounts.get('container:first')! + 1,
)
expect(selectionStateRenderCounts.get('item:first')).toBe(
initialRenderCounts.get('item:first')! + 1,
)
expect(selectionStateRenderCounts.get('container:second')).toBe(
initialRenderCounts.get('container:second'),
)
expect(selectionStateRenderCounts.get('item:second')).toBe(
initialRenderCounts.get('item:second'),
)
const renderCounts = new Map(selectionStateRenderCounts)
editor.commands.setTextSelection(4)
await flushMicrotasks()
expect(selectionStateRenderCounts).toEqual(renderCounts)
editor.destroy()
})
it('keeps a node selected when its NodeSelection moves to an internal text selection', async () => {
const editor = createEditorWithContainers({ selectedOnTextSelection: true })
const { container } = await renderEditor(editor)
editor.commands.setNodeSelection(0)
await flushMicrotasks()
const renderCount = selectionStateRenderCounts.get('container:first')!
editor.commands.setTextSelection(3)
await flushMicrotasks()
const selectedState = container.querySelector('[data-node-view-selected]')!
expect(selectedState.getAttribute('data-node-view-selected')).toBe('true')
expect(selectionStateRenderCounts.get('container:first')).toBe(renderCount + 1)
editor.destroy()
})
it('deselects a node when its NodeSelection moves to an external text selection', async () => {
const editor = createEditorWithContainers({ selectedOnTextSelection: true })
const { container } = await renderEditor(editor)
editor.commands.setNodeSelection(0)
await flushMicrotasks()
const secondContainerPosition = editor.state.doc.firstChild!.nodeSize
editor.commands.setTextSelection(secondContainerPosition + 3)
await flushMicrotasks()
expect(getSelectedStates(container)).toEqual(['false', 'false', 'true', 'true'])
editor.destroy()
})
it('keeps new React paragraph content connected while its portal is queued', async () => {
const editor = createEditorWithReactParagraph()
const { container } = await renderEditor(editor)
editor.commands.setTextSelection(6)
editor.commands.splitBlock()
const secondParagraphPosition = editor.state.doc.firstChild!.nodeSize
expect(editor.state.selection.from).toBe(secondParagraphPosition + 1)
const contentElements = container.querySelectorAll('[data-node-view-content-react]')
expect(contentElements).toHaveLength(2)
expect(contentElements[1].isConnected).toBe(true)
editor.commands.insertContent('Second')
expect(editor.state.doc.child(0).textContent).toBe('Hello')
expect(editor.state.doc.child(1).textContent).toBe('Second')
await flushMicrotasks()
expect(contentElements[1].parentElement?.hasAttribute('data-node-view-content')).toBe(true)
editor.destroy()
})
it('resolves getPos to undefined while the view desc is detached mid-update', async () => {
const editor = createEditorWithContainers()
const { container } = await renderEditor(editor)
// Recreate the state ProseMirror's view tree goes through while it
// updates: the desc has a parent but is not in parent.children yet.
// getPos then walks past the end of the children array and throws.
const desc = (container.querySelector('.react-renderer') as any).pmViewDesc
const siblings = desc.parent.children
siblings.splice(siblings.indexOf(desc), 1)
renderedPositions.length = 0
await act(async () => {
bumpHandles.get('a')?.()
})
// put the tree back so teardown works on a consistent view
siblings.unshift(desc)
expect(renderErrors).toEqual([])
expect(renderedPositions).toEqual([undefined])
editor.destroy()
})
it('does not select the node view when the selection covers its former position', async () => {
const editor = createEditorWithWidget()
const { container } = await renderEditor(editor)
// The widget starts at position 5 and moves to 8 when text is typed above it.
editor.commands.insertContentAt(4, 'def')
editor.commands.setTextSelection({ from: 5, to: 6 })
await flushAnimationFrame()
const widget = container.querySelector('.node-widget')!
expect(widget.classList.contains('ProseMirror-selectednode')).toBe(false)
editor.destroy()
})
it('selects the node view when it is selected at its new position', async () => {
const editor = createEditorWithWidget()
const { container } = await renderEditor(editor)
editor.commands.insertContentAt(4, 'def')
editor.commands.setNodeSelection(8)
await flushAnimationFrame()
const widget = container.querySelector('.node-widget')!
expect(widget.classList.contains('ProseMirror-selectednode')).toBe(true)
editor.destroy()
})
it('does not crash when node views are created while React has pending updates', async () => {
const editor = createEditorWithContainers()
const { container } = await renderEditor(editor)
expect(() => clickReplace('b')).not.toThrow()
await flushMicrotasks()
// getPos must not blow up inside the synchronous React flush.
expect(renderErrors).toEqual([])
// The editor and the React tree must stay intact and usable.
expect(editor.state.doc.firstChild?.attrs.id).toBe('fresh')
expect(container.querySelector('.tiptap')).not.toBeNull()
expect(
editor.commands.insertContentAt(editor.state.doc.content.size, { type: 'paragraph' }),
).toBe(true)
editor.destroy()
})
it('keeps the editor intact when a component throws during the synchronous flush', async () => {
const editor = createEditorWithContainers()
await renderEditor(editor)
// Make container "a" throw on its next render and give it a pending
// update, so the new node view's flushSync renders it mid-transaction.
beforeReplace = () => {
explodingIds.add('a')
bumpHandles.get('a')?.()
}
let clickError: unknown
try {
clickReplace('b')
} catch (error) {
clickError = error
}
await flushMicrotasks()
// React 19 reports the render error via onUncaughtError instead of
// rethrowing, so nothing may propagate into the ProseMirror transaction.
expect(clickError).toBeUndefined()
// The ProseMirror transaction must complete even though a component
// render threw during the flush.
expect(editor.state.doc.firstChild?.attrs.id).toBe('fresh')
expect(
editor.commands.insertContentAt(editor.state.doc.content.size, { type: 'paragraph' }),
).toBe(true)
editor.destroy()
})
})

View File

@@ -0,0 +1,462 @@
import type {
DecorationWithType,
Editor,
NodeViewRenderer,
NodeViewRendererOptions,
NodeViewRendererProps,
} from '@tiptap/core'
import { getRenderedAttributes, NodeView } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import type { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
import type { ComponentType, NamedExoticComponent } from 'react'
import { createElement, createRef, memo } from 'react'
import { captureDOMSelection } from './captureDOMSelection.js'
import type { EditorWithContentComponent } from './Editor.js'
import { getTextSelectionAncestorPositions } from './lib/utils/getTextSelectionAncestorPositions.js'
import { getReactNodeViewSelectionTracker } from './ReactNodeViewSelectionTracker.js'
import { ReactRenderer } from './ReactRenderer.js'
import type { ReactNodeViewProps } from './types.js'
import type { ReactNodeViewContextProps } from './useReactNodeView.js'
import { ReactNodeViewContext } from './useReactNodeView.js'
export interface ReactNodeViewRendererOptions extends NodeViewRendererOptions {
/**
* @deprecated Read `selectionInside` from the node view props instead.
*/
selectedOnTextSelection?: boolean
/**
* This function is called when the node view is updated.
* It allows you to compare the old node with the new node and decide if the component should update.
*/
update:
| ((props: {
oldNode: PMNode
oldDecorations: readonly Decoration[]
oldInnerDecorations: DecorationSource
newNode: PMNode
newDecorations: readonly Decoration[]
innerDecorations: DecorationSource
updateProps: () => void
}) => boolean)
| null
/**
* The tag name of the element wrapping the React component.
*/
as?: string
/**
* The class name of the element wrapping the React component.
*/
className?: string
/**
* Attributes that should be applied to the element wrapping the React component.
* If this is a function, it will be called each time the node view is updated.
* If this is an object, it will be applied once when the node view is mounted.
*/
attrs?:
| Record<string, string>
| ((props: { node: PMNode; HTMLAttributes: Record<string, any> }) => Record<string, string>)
}
export class ReactNodeView<
T = HTMLElement,
Component extends ComponentType<ReactNodeViewProps<T>> = ComponentType<ReactNodeViewProps<T>>,
NodeEditor extends Editor = Editor,
Options extends ReactNodeViewRendererOptions = ReactNodeViewRendererOptions,
> extends NodeView<Component, NodeEditor, Options> {
/**
* The renderer instance.
*/
renderer!: ReactRenderer<unknown, ReactNodeViewProps<T>>
/**
* The element that holds the rich-text content of the node.
*/
contentDOMElement!: HTMLElement | null
/**
* The last known position of this node view, used to detect position-only
* changes that don't produce a new node object reference.
*/
private currentPos: number | undefined
private nodeSelected = false
/**
* Fires on editor updates when trackNodeViewPosition is enabled.
* Detects position shifts where update() is NOT called (e.g. typing above).
*/
private handlePositionUpdate = () => {
const newPos = this.getPos()
if (typeof newPos !== 'number' || newPos === this.currentPos) {
return
}
this.currentPos = newPos
this.renderer.updateProps({ getPos: () => this.getPos() })
if (typeof this.options.attrs === 'function') {
this.updateElementAttributes()
}
}
constructor(component: Component, props: NodeViewRendererProps, options?: Partial<Options>) {
super(component, props, options)
if (!this.node.isLeaf) {
if (this.options.contentDOMElementTag) {
this.contentDOMElement = document.createElement(this.options.contentDOMElementTag)
} else {
this.contentDOMElement = document.createElement(this.node.isInline ? 'span' : 'div')
}
this.contentDOMElement.dataset.nodeViewContentReact = ''
this.contentDOMElement.dataset.nodeViewWrapper = ''
// For some reason the whiteSpace prop is not inherited properly in Chrome and Safari
// With this fix it seems to work fine
// See: https://github.com/ueberdosis/tiptap/issues/1197
this.contentDOMElement.style.whiteSpace = 'inherit'
const contentTarget = this.dom.querySelector('[data-node-view-content]')
if (contentTarget) {
contentTarget.appendChild(this.contentDOMElement)
} else {
// ProseMirror maps the selection before the queued portal render.
this.dom.appendChild(this.contentDOMElement)
}
}
if (this.options.trackNodeViewPosition) {
this.editor.on('update', this.handlePositionUpdate)
}
}
private cachedExtensionWithSyncedStorage: NodeViewRendererProps['extension'] | null = null
/**
* Returns a proxy of the extension that redirects storage access to the editor's mutable storage.
* This preserves the original prototype chain (instanceof checks, methods like configure/extend work).
* Cached to avoid proxy creation on every update.
*/
get extensionWithSyncedStorage(): NodeViewRendererProps['extension'] {
if (!this.cachedExtensionWithSyncedStorage) {
const editor = this.editor
const extension = this.extension
this.cachedExtensionWithSyncedStorage = new Proxy(extension, {
get(target, prop, receiver) {
if (prop === 'storage') {
return editor.storage[extension.name as keyof typeof editor.storage] ?? {}
}
return Reflect.get(target, prop, receiver)
},
})
}
return this.cachedExtensionWithSyncedStorage
}
/**
* Setup the React component.
* Called on initialization.
*/
mount() {
const props: Record<string, any> = {
editor: this.editor,
node: this.node,
decorations: this.decorations as DecorationWithType[],
innerDecorations: this.innerDecorations,
view: this.view,
selected: false,
selectionInside: false,
extension: this.extensionWithSyncedStorage,
HTMLAttributes: this.HTMLAttributes,
getPos: () => this.getPos(),
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
deleteNode: () => this.deleteNode(),
ref: createRef<T>(),
}
const mountProps = props as ReactNodeViewProps<T>
if (!(this.component as any).displayName) {
const capitalizeFirstChar = (string: string): string => {
return string.charAt(0).toUpperCase() + string.substring(1)
}
this.component.displayName = capitalizeFirstChar(this.extension.name)
}
const onDragStart = this.onDragStart.bind(this)
const nodeViewContentRef: ReactNodeViewContextProps['nodeViewContentRef'] = element => {
if (element && this.contentDOMElement && element.firstChild !== this.contentDOMElement) {
// remove the nodeViewWrapper attribute from the element
if (element.hasAttribute('data-node-view-wrapper')) {
element.removeAttribute('data-node-view-wrapper')
}
// The caret can already be inside here. Moving would lose it.
const restoreSelection = captureDOMSelection(this.contentDOMElement)
element.appendChild(this.contentDOMElement)
restoreSelection?.()
}
}
const context = { onDragStart, nodeViewContentRef }
const Component = this.component
// For performance reasons, we memoize the provider component
// And all of the things it requires are declared outside of the component, so it doesn't need to re-render
const ReactNodeViewProvider: NamedExoticComponent<ReactNodeViewProps<T>> = memo(
componentProps => {
return (
<ReactNodeViewContext.Provider value={context}>
{createElement(Component, componentProps)}
</ReactNodeViewContext.Provider>
)
},
)
ReactNodeViewProvider.displayName = 'ReactNodeView'
let as = this.node.isInline ? 'span' : 'div'
if (this.options.as) {
as = this.options.as
}
const { className = '' } = this.options
this.renderer = new ReactRenderer(ReactNodeViewProvider, {
editor: this.editor,
props: mountProps,
as,
className: `node-${this.node.type.name} ${className}`.trim(),
})
getReactNodeViewSelectionTracker(this.editor).register(this)
this.updateElementAttributes()
this.currentPos = this.getPos()
}
/**
* Return the DOM element.
* This is the element that will be used to display the node view.
*/
get dom() {
if (
this.renderer.element.firstElementChild &&
!this.renderer.element.firstElementChild?.hasAttribute('data-node-view-wrapper')
) {
throw Error('Please use the NodeViewWrapper component for your node view.')
}
return this.renderer.element
}
/**
* Return the content DOM element.
* This is the element that will be used to display the rich-text content of the node.
*/
get contentDOM() {
if (this.node.isLeaf) {
return null
}
return this.contentDOMElement
}
/**
* On update, update the React component.
* To prevent unnecessary updates, the `update` option can be used.
*/
update(
node: PMNode,
decorations: readonly Decoration[],
innerDecorations: DecorationSource,
): boolean {
const rerenderComponent = (props?: Record<string, any>) => {
this.renderer.updateProps(props)
if (typeof this.options.attrs === 'function') {
this.updateElementAttributes()
}
}
if (node.type !== this.node.type) {
return false
}
if (typeof this.options.update === 'function') {
const oldNode = this.node
const oldDecorations = this.decorations
const oldInnerDecorations = this.innerDecorations
this.node = node
this.decorations = decorations
this.innerDecorations = innerDecorations
this.currentPos = this.getPos()
return this.options.update({
oldNode,
oldDecorations,
newNode: node,
newDecorations: decorations,
oldInnerDecorations,
innerDecorations,
updateProps: () =>
rerenderComponent({
node,
decorations,
innerDecorations,
extension: this.extensionWithSyncedStorage,
}),
})
}
const nodeChanged = node !== this.node
// Node reference unchanged — only decorations may have changed.
// ProseMirror renders decorations independently on the contentDOM,
// and the getPos closure (bound in mount()) calls through to
// ProseMirror's position function at call time, so it is always
// current. Update internal refs and skip the React re-render.
// Keep currentPos unchanged here so the editor update listener can
// still detect and publish a position shift for tracked node views.
if (!nodeChanged) {
this.node = node
this.decorations = decorations
this.innerDecorations = innerDecorations
return true
}
const newPos = this.getPos()
this.node = node
this.decorations = decorations
this.innerDecorations = innerDecorations
this.currentPos = newPos
const extraProps: Record<string, any> = {
node,
decorations,
innerDecorations,
extension: this.extensionWithSyncedStorage,
}
if (this.options.trackNodeViewPosition) {
extraProps.getPos = () => this.getPos()
}
rerenderComponent(extraProps)
return true
}
/**
* Select the node.
* Add the `selected` prop and the `ProseMirror-selectednode` class.
*/
selectNode() {
this.nodeSelected = true
this.updateSelectedState(true)
}
/**
* Deselect the node.
* Remove the `selected` prop and the `ProseMirror-selectednode` class.
*/
deselectNode() {
this.nodeSelected = false
this.updateSelectedState(
this.options.selectedOnTextSelection === true && this.isTextSelectionInside(),
)
}
setSelectionInside(selectionInside: boolean) {
const selected =
this.nodeSelected || (this.options.selectedOnTextSelection === true && selectionInside)
this.renderer.updateProps({ selectionInside, selected })
this.renderer.element.classList.toggle('ProseMirror-selectednode', selected)
}
private updateSelectedState(selected: boolean) {
this.renderer.updateProps({ selected })
this.renderer.element.classList.toggle('ProseMirror-selectednode', selected)
}
private isTextSelectionInside() {
const pos = this.getPos()
return (
typeof pos === 'number' &&
getTextSelectionAncestorPositions(this.editor.state.selection).includes(pos)
)
}
/**
* Destroy the React component instance.
*/
destroy() {
this.renderer.destroy()
getReactNodeViewSelectionTracker(this.editor).unregister(this)
if (this.options.trackNodeViewPosition) {
this.editor.off('update', this.handlePositionUpdate)
}
this.contentDOMElement = null
}
/**
* Update the attributes of the top-level element that holds the React component.
* Applying the attributes defined in the `attrs` option.
*/
updateElementAttributes() {
if (this.options.attrs) {
let attrsObj: Record<string, string> = {}
if (typeof this.options.attrs === 'function') {
const extensionAttributes = this.editor.extensionManager.attributes
const HTMLAttributes = getRenderedAttributes(this.node, extensionAttributes)
attrsObj = this.options.attrs({ node: this.node, HTMLAttributes })
} else {
attrsObj = this.options.attrs
}
this.renderer.updateAttributes(attrsObj)
}
}
}
/**
* Create a React node view renderer.
*/
export function ReactNodeViewRenderer<T = HTMLElement>(
component: ComponentType<ReactNodeViewProps<T>>,
options?: Partial<ReactNodeViewRendererOptions>,
): NodeViewRenderer {
return props => {
// try to get the parent component
// this is important for vue devtools to show the component hierarchy correctly
// maybe it's `undefined` because <editor-content> isn't rendered yet
if (!(props.editor as EditorWithContentComponent).contentComponent) {
// Return a minimal valid NodeView with a placeholder DOM element
// to prevent ProseMirror from crashing when calling hasAttribute on undefined
const placeholder = document.createElement('span')
return {
dom: placeholder,
contentDOM: null,
update: () => false,
destroy: () => {},
selectNode: () => {},
deselectNode: () => {},
stopEvent: () => false,
ignoreMutation: () => true,
} as unknown as ProseMirrorNodeView
}
return new ReactNodeView<T>(component, props, options)
}
}

View File

@@ -0,0 +1,113 @@
import type { Editor } from '@tiptap/core'
import { getTextSelectionAncestorPositions } from './lib/utils/getTextSelectionAncestorPositions.js'
/** A React node view that receives `selectionInside` updates. */
interface TrackedNodeView {
dom: HTMLElement
setSelectionInside(selectionInside: boolean): void
}
class ReactNodeViewSelectionTracker {
private editor: Editor
private views = new WeakMap<Node, TrackedNodeView>()
private viewCount = 0
private insideViews = new Set<TrackedNodeView>()
private syncQueued = false
constructor(editor: Editor) {
this.editor = editor
}
register(view: TrackedNodeView) {
if (this.viewCount === 0) {
this.editor.on('transaction', this.handleTransaction)
}
this.views.set(view.dom, view)
this.viewCount += 1
this.scheduleSync()
}
unregister(view: TrackedNodeView) {
this.views.delete(view.dom)
this.viewCount -= 1
this.insideViews.delete(view)
if (this.viewCount === 0) {
this.editor.off('transaction', this.handleTransaction)
}
}
private scheduleSync = () => {
if (this.syncQueued) {
return
}
this.syncQueued = true
queueMicrotask(() => {
this.syncQueued = false
this.sync()
})
}
private handleTransaction = () => this.scheduleSync()
private sync() {
if (this.viewCount === 0 || this.editor.isDestroyed) {
return
}
const nextInsideViews = this.findInsideViews()
this.updateViews(this.insideViews, nextInsideViews, false)
this.updateViews(nextInsideViews, this.insideViews, true)
this.insideViews = nextInsideViews
}
private updateViews(
views: Set<TrackedNodeView>,
unchangedViews: Set<TrackedNodeView>,
selectionInside: boolean,
) {
for (const view of views) {
if (!unchangedViews.has(view)) {
view.setSelectionInside(selectionInside)
}
}
}
private findInsideViews(): Set<TrackedNodeView> {
const insideViews = new Set<TrackedNodeView>()
for (const position of getTextSelectionAncestorPositions(this.editor.state.selection)) {
const dom = this.editor.view.nodeDOM(position)
const view = dom ? this.views.get(dom) : undefined
if (view) {
insideViews.add(view)
}
}
return insideViews
}
}
const trackers = new WeakMap<Editor, ReactNodeViewSelectionTracker>()
export function getReactNodeViewSelectionTracker(editor: Editor): ReactNodeViewSelectionTracker {
let tracker = trackers.get(editor)
if (!tracker) {
tracker = new ReactNodeViewSelectionTracker(editor)
trackers.set(editor, tracker)
}
return tracker
}

View File

@@ -0,0 +1,304 @@
import type { Editor } from '@tiptap/core'
import type {
ComponentClass,
ForwardRefExoticComponent,
FunctionComponent,
PropsWithoutRef,
ReactNode,
RefAttributes,
} from 'react'
import { version as reactVersion } from 'react'
import { flushSync } from 'react-dom'
import type { EditorWithContentComponent } from './Editor.js'
/**
* Check if a component is a class component.
* @param Component
* @returns {boolean}
*/
function isClassComponent(Component: any) {
return !!(
typeof Component === 'function' &&
Component.prototype &&
Component.prototype.isReactComponent
)
}
/**
* Check if a component is a forward ref component.
* @param Component
* @returns {boolean}
*/
function isForwardRefComponent(Component: any) {
return !!(
typeof Component === 'object' &&
Component.$$typeof &&
(Component.$$typeof.toString() === 'Symbol(react.forward_ref)' ||
Component.$$typeof.description === 'react.forward_ref')
)
}
/**
* Check if a component is a memoized component.
* @param Component
* @returns {boolean}
*/
function isMemoComponent(Component: any) {
return !!(
typeof Component === 'object' &&
Component.$$typeof &&
(Component.$$typeof.toString() === 'Symbol(react.memo)' ||
Component.$$typeof.description === 'react.memo')
)
}
/**
* Check if a component can safely receive a ref prop.
* This includes class components, forwardRef components, and memoized components
* that wrap forwardRef or class components.
* @param Component
* @returns {boolean}
*/
function canReceiveRef(Component: any) {
// Check if it's a class component
if (isClassComponent(Component)) {
return true
}
// Check if it's a forwardRef component
if (isForwardRefComponent(Component)) {
return true
}
// Check if it's a memoized component
if (isMemoComponent(Component)) {
// For memoized components, check the wrapped component
const wrappedComponent = Component.type
if (wrappedComponent) {
return isClassComponent(wrappedComponent) || isForwardRefComponent(wrappedComponent)
}
}
return false
}
/**
* Check if we're running React 19+ by detecting if function components support ref props
* @returns {boolean}
*/
function isReact19Plus(): boolean {
// React 19 is detected by checking React version if available
// In practice, we'll use a more conservative approach and assume React 18 behavior
// unless we can definitively detect React 19
try {
// @ts-ignore
if (reactVersion) {
const majorVersion = parseInt(reactVersion.split('.')[0], 10)
return majorVersion >= 19
}
} catch {
// Fallback to React 18 behavior if we can't determine version
}
return false
}
export interface ReactRendererOptions {
/**
* The editor instance.
* @type {Editor}
*/
editor: Editor
/**
* The props for the component.
* @type {Record<string, any>}
* @default {}
*/
props?: Record<string, any>
/**
* The tag name of the element.
* @type {string}
* @default 'div'
*/
as?: string
/**
* The class name of the element.
* @type {string}
* @default ''
* @example 'foo bar'
*/
className?: string
}
type ComponentType<R, P> =
| ComponentClass<P>
| FunctionComponent<P>
| ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<R>>
/**
* The ReactRenderer class. It's responsible for rendering React components inside the editor.
* @example
* new ReactRenderer(MyComponent, {
* editor,
* props: {
* foo: 'bar',
* },
* as: 'span',
* })
*/
export class ReactRenderer<R = unknown, P extends Record<string, any> = object> {
id: string
editor: EditorWithContentComponent
component: any
element: HTMLElement
props: P
reactElement: ReactNode
ref: R | null = null
/**
* Flag to track if the renderer has been destroyed, preventing queued or asynchronous renders from executing after teardown.
*/
destroyed = false
/**
* Immediately creates element and renders the provided React component.
*/
constructor(
component: ComponentType<R, P>,
{ editor, props = {}, as = 'div', className = '' }: ReactRendererOptions,
) {
this.id = Math.floor(Math.random() * 0xffffffff).toString()
this.component = component
this.editor = editor
this.props = props as P
this.element = document.createElement(as)
this.element.classList.add('react-renderer')
if (className) {
this.element.classList.add(...className.split(' '))
}
if (this.editor.isEditorContentInitialized) {
// If EditorContent is mounted, flush synchronously to maintain cursor positioning consistency.
// Subsequent renders can be async without affecting cursor behavior.
flushSync(() => {
this.render()
})
} else {
queueMicrotask(() => {
if (this.destroyed) {
return
}
this.render()
})
}
}
/**
* Render the React component.
*/
render(): void {
if (this.destroyed) {
return
}
const Component = this.component
const props = this.props
const editor = this.editor as EditorWithContentComponent
// Handle ref forwarding with React 18/19 compatibility
const isReact19 = isReact19Plus()
const componentCanReceiveRef = canReceiveRef(Component)
const elementProps = { ...props }
// Always remove ref if the component cannot receive it (unless React 19+)
if (elementProps.ref && !(isReact19 || componentCanReceiveRef)) {
delete elementProps.ref
}
// Only assign our own ref if allowed
if (!elementProps.ref && (isReact19 || componentCanReceiveRef)) {
// @ts-ignore - Setting ref prop for compatible components
elementProps.ref = (ref: R) => {
this.ref = ref
}
}
this.reactElement = <Component {...elementProps} />
editor?.contentComponent?.setRenderer(this.id, this)
}
/**
* Re-renders the React component with new props.
* Skips the render if none of the supplied props actually changed,
* to avoid unnecessary portal re-creation.
*/
updateProps(props: Record<string, any> = {}): void {
if (this.destroyed) {
return
}
// Shallow comparison — skip if every supplied key already holds the same value.
let changed = false
const keys = Object.keys(props)
for (let i = 0; i < keys.length; i += 1) {
const key = keys[i]
if (props[key] !== this.props[key]) {
changed = true
break
}
}
if (!changed) {
return
}
this.props = {
...this.props,
...props,
}
this.render()
}
/**
* Destroy the React component.
*/
destroy(): void {
this.destroyed = true
const editor = this.editor as EditorWithContentComponent
editor?.contentComponent?.removeRenderer(this.id)
// If the consumer appended the element to the document (for example
// many demos append the renderer element to document.body), make sure
// we remove it here to avoid leaking DOM nodes / React roots.
try {
if (this.element && this.element.parentNode) {
this.element.parentNode.removeChild(this.element)
}
} catch {
// ignore DOM removal errors
}
}
/**
* Update the attributes of the element that holds the React component.
*/
updateAttributes(attributes: Record<string, string>): void {
Object.keys(attributes).forEach(key => {
this.element.setAttribute(key, attributes[key])
})
}
}

View File

@@ -0,0 +1,448 @@
import { Editor, Extension } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { render, waitFor } from '@testing-library/react'
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import { EditorContent } from './EditorContent.js'
import { ReactWidgetRenderer } from './ReactWidgetRenderer.js'
import { UndoRedo } from '@tiptap/extensions'
vi.mock('@tiptap/core/jsx-runtime', async () => import('react/jsx-runtime'))
vi.mock('@tiptap/core/jsx-dev-runtime', async () => import('react/jsx-dev-runtime'))
// The lifecycle tests below use this lightweight component with a mock content
// component. Separate tests mount widgets through a real React root.
const Widget = () => null
let widgetRenderCount = 0
const propLog: Array<{ editor: Editor; getPos: unknown }> = []
const RenderCounter = () => {
widgetRenderCount += 1
return React.createElement('span', { className: 'render-counter' })
}
const PropSpy = ({ editor, getPos }: { editor: Editor; getPos: unknown }) => {
propLog.push({ editor, getPos })
return React.createElement('span', { className: 'prop-spy' })
}
const AttributeWidget = ({ index }: { index: number }) =>
React.createElement('span', { className: 'attribute-widget' }, index)
function makeContentComponent() {
const live = new Set<string>()
return {
live,
setRenderer(id: string) {
live.add(id)
},
removeRenderer(id: string) {
live.delete(id)
},
subscribe: () => () => {},
getSnapshot: () => ({}),
getServerSnapshot: () => ({}),
}
}
/**
* A widget per paragraph, keyed by paragraph index.
*
* NOTE: Index-based keys churn when paragraphs are inserted/removed. This is
* intentional for testing re-keying behavior. In production, use stable domain
* keys (e.g. `paragraph-${node.attrs.id}`) so the widget DOM and component
* state are preserved across edits.
*/
function paragraphWidgets() {
return Extension.create({
name: 'paragraphWidgets',
addDecorations() {
return {
create: ({ editor, state }) => {
const decorations: any[] = []
let index = 0
state.doc.forEach((node, offset) => {
if (node.type.name !== 'paragraph') {
return
}
decorations.push(
ReactWidgetRenderer(Widget, {
editor,
pos: offset + node.nodeSize - 1,
key: `p-${index}`,
side: 1,
}),
)
index += 1
})
return decorations
},
}
},
})
}
function unchangedPropsWidget() {
return Extension.create({
name: 'unchangedPropsWidget',
addDecorations() {
return {
create: ({ editor, state }) => {
const first = state.doc.firstChild
if (!first) {
return []
}
return [
ReactWidgetRenderer(RenderCounter, {
editor,
pos: first.nodeSize - 1,
key: 'render-counter',
props: { label: 'constant' },
}),
]
},
}
},
})
}
function propSpyWidget() {
return Extension.create({
name: 'propSpyWidget',
addDecorations() {
return {
create: ({ editor, state }) => {
const first = state.doc.firstChild
if (!first) {
return []
}
return [
ReactWidgetRenderer(PropSpy, {
editor,
pos: first.nodeSize - 1,
key: 'prop-spy',
props: { size: state.doc.content.size },
}),
]
},
}
},
})
}
function copiedKeyWidgets() {
return Extension.create({
name: 'copiedKeyWidgets',
addGlobalAttributes: () => [{ types: ['paragraph'], attributes: { id: { default: null } } }],
addDecorations: () => ({
create: ({ editor, state }) => {
const decorations: any[] = []
let index = 0
state.doc.forEach((node, pos) => {
if (node.type.name === 'paragraph') {
decorations.push(
ReactWidgetRenderer(AttributeWidget, {
editor,
pos: pos + node.nodeSize - 1,
key: `paragraph-${node.attrs.id}`,
props: { index },
}),
)
index += 1
}
})
return decorations
},
}),
addProseMirrorPlugins: () => [
new Plugin({
key: new PluginKey('repairCopiedIds'),
appendTransaction: (transactions, _oldState, newState) => {
if (!transactions.some(transaction => transaction.docChanged)) {
return
}
const ids = new Set<string>()
const tr = newState.tr
newState.doc.forEach((node, pos) => {
const id = node.attrs.id
if (node.type.name === 'paragraph' && id && ids.has(id)) {
tr.setNodeAttribute(pos, 'id', `${id}-copy`)
}
ids.add(id)
})
return tr.steps.length ? tr : undefined
},
}),
],
})
}
function createEditor(content: string) {
const contentComponent = makeContentComponent()
const editor = new Editor({
extensions: [Document, Paragraph, Text, paragraphWidgets()],
content,
})
;(editor as any).contentComponent = contentComponent
;(editor as any).isEditorContentInitialized = true
return { editor, contentComponent }
}
function createEditorWithUndo(content: string) {
const contentComponent = makeContentComponent()
const editor = new Editor({
extensions: [Document, Paragraph, Text, paragraphWidgets(), UndoRedo],
content,
})
;(editor as any).contentComponent = contentComponent
;(editor as any).isEditorContentInitialized = true
return { editor, contentComponent }
}
function createMountedEditor(content: string, extension: Extension) {
const editor = new Editor({
extensions: [Document, Paragraph, Text, extension],
content,
})
const mounted = render(React.createElement(EditorContent, { editor }))
return { editor, ...mounted }
}
async function flush() {
await Promise.resolve()
await new Promise(resolve => setTimeout(resolve, 0))
}
describe('ReactWidgetRenderer', () => {
let active: Editor | null = null
let unmount: (() => void) | null = null
afterEach(() => {
unmount?.()
unmount = null
active?.destroy()
active = null
widgetRenderCount = 0
propLog.length = 0
})
it('registers a portal for every widget', async () => {
const { editor, contentComponent } = createEditor('<p>a</p><p>b</p>')
active = editor
await flush()
expect(contentComponent.live.size).toBe(2)
})
it('passes ProseMirror widget options through', () => {
const stopEvent = vi.fn(() => true)
const destroy = vi.fn()
const extension = Extension.create({
name: 'widgetOptions',
addDecorations: () => ({
create: ({ editor }) => [
ReactWidgetRenderer(Widget, {
editor,
pos: 1,
key: 'options',
side: -1,
relaxedSide: true,
ignoreSelection: true,
stopEvent,
destroy,
}),
],
}),
})
const editor = new Editor({
extensions: [Document, Paragraph, Text, extension],
content: '<p>a</p>',
})
const decorationState = editor.state.plugins
.find(plugin => plugin.props.decorations)
?.getState(editor.state) as
| { mergedDecorationSet?: { find: () => Array<{ spec: Record<string, unknown> }> } }
| undefined
const widget = decorationState?.mergedDecorationSet?.find()[0]
expect(widget?.spec).toMatchObject({
side: -1,
relaxedSide: true,
ignoreSelection: true,
stopEvent,
destroy: expect.any(Function),
})
editor.destroy()
expect(destroy).not.toHaveBeenCalled()
})
it('does not render a mounted widget again when props are unchanged', async () => {
const mounted = createMountedEditor('<p>a</p>', unchangedPropsWidget())
active = mounted.editor
unmount = mounted.unmount
await waitFor(() => expect(widgetRenderCount).toBeGreaterThan(0))
const rendersAfterMount = widgetRenderCount
mounted.editor.commands.insertContentAt(2, 'X')
await flush()
expect(widgetRenderCount).toBe(rendersAfterMount)
})
it('keeps editor and getPos when mounted widget props change', async () => {
const mounted = createMountedEditor('<p>a</p>', propSpyWidget())
active = mounted.editor
unmount = mounted.unmount
await waitFor(() => expect(propLog.length).toBeGreaterThan(0))
mounted.editor.commands.insertContentAt(2, 'X')
await flush()
mounted.editor.commands.insertContentAt(2, 'Y')
await flush()
expect(propLog.length).toBeGreaterThan(1)
expect(propLog.every(entry => entry.editor === mounted.editor)).toBe(true)
expect(propLog.every(entry => typeof entry.getPos === 'function')).toBe(true)
})
it('keeps every widget registered when keys are reassigned by a split', async () => {
const { editor, contentComponent } = createEditor('<p>a</p><p>b</p>')
active = editor
await flush()
expect(contentComponent.live.size).toBe(2)
// Splitting inserts a paragraph; index-based keys churn (the old second
// paragraph's key shifts), which previously clobbered a reused renderer.
editor.chain().setTextSelection(2).splitBlock().run()
await flush()
expect(editor.state.doc.childCount).toBe(3)
expect(contentComponent.live.size).toBe(3)
})
it('keeps props from the final state when an appended transaction repairs a copied key', async () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, copiedKeyWidgets()],
content: {
type: 'doc',
content: [
{ type: 'paragraph', attrs: { id: 'first' }, content: [{ type: 'text', text: 'a' }] },
{ type: 'paragraph', attrs: { id: 'second' }, content: [{ type: 'text', text: 'b' }] },
],
},
})
const mounted = render(React.createElement(EditorContent, { editor }))
active = editor
unmount = mounted.unmount
editor.chain().setTextSelection(2).splitBlock().run()
await flush()
expect(Array.from(mounted.container.querySelectorAll('.attribute-widget'))).toHaveLength(3)
expect(
Array.from(mounted.container.querySelectorAll('.attribute-widget')).map(widget =>
widget.textContent?.trim(),
),
).toEqual(['0', '1', '2'])
})
it('tears down the renderer when a widget is genuinely removed', async () => {
const { editor, contentComponent } = createEditor('<p>aaa</p><p>bbb</p>')
active = editor
await flush()
expect(contentComponent.live.size).toBe(2)
// Join the two paragraphs into one — the second widget should be removed.
editor.chain().setTextSelection(6).joinBackward().run()
await flush()
expect(editor.state.doc.childCount).toBe(1)
expect(contentComponent.live.size).toBe(1)
})
it('tears down stale widgets when setContent replaces the whole document', async () => {
const { editor, contentComponent } = createEditor('<p>a</p><p>b</p><p>c</p>')
active = editor
await flush()
expect(contentComponent.live.size).toBe(3)
editor.commands.setContent('<p>only</p>')
await flush()
expect(editor.state.doc.childCount).toBe(1)
expect(contentComponent.live.size).toBe(1)
})
it('destroys all active widget renderers when the editor is destroyed', async () => {
const { editor, contentComponent } = createEditor('<p>a</p><p>b</p>')
// Don't assign to `active` — we destroy manually before afterEach.
const activeRef = editor
await flush()
expect(contentComponent.live.size).toBe(2)
activeRef.destroy()
await flush()
expect(contentComponent.live.size).toBe(0)
})
it('does not leak renderers after undo of a paragraph split', async () => {
const { editor, contentComponent } = createEditorWithUndo('<p>a</p><p>b</p>')
active = editor
await flush()
expect(contentComponent.live.size).toBe(2)
// Split first paragraph — 3 widgets.
editor.chain().setTextSelection(2).splitBlock().run()
await flush()
expect(editor.state.doc.childCount).toBe(3)
expect(contentComponent.live.size).toBe(3)
// Undo removes the split-created paragraph, which destroys its widget.
// The 1 removal is correct cleanup — not a leak.
editor.commands.undo()
await flush()
expect(editor.state.doc.childCount).toBe(2)
expect(contentComponent.live.size).toBe(2)
})
// Duplicate widget keys intentionally not tested here — ProseMirror's view
// crashes when it encounters same-key widgets, and the type-level contract
// ("keys must be unique") is documented on WidgetDecoration.key.
})

View File

@@ -0,0 +1,97 @@
import { createWidgetDecoration } from '@tiptap/core'
import type { Editor, WidgetDecoration, WidgetDecorationOptions } from '@tiptap/core'
import type { ComponentType } from 'react'
import { ReactRenderer } from './ReactRenderer.js'
/**
* Props every widget-decoration component receives in addition to the props you
* pass through `ReactWidgetRenderer`.
*/
export interface ReactWidgetDecorationProps {
editor: Editor
getPos: () => number | undefined
}
export interface ReactWidgetRendererOptions<
P extends Record<string, any> = object,
> extends WidgetDecorationOptions {
/**
* The editor instance.
*/
editor: Editor
/**
* The document position the widget is rendered at.
*/
pos: number
/**
* A stable, position-independent identifier for the widget.
* Reusing the same key keeps the component mounted across re-renders.
* Good: `comment-${id}`. Bad: paragraph index or document position.
*/
key: string
/**
* Props passed to the component (merged with {@link ReactWidgetDecorationProps}).
*/
props?: P
/**
* The tag name of the wrapper element. Defaults to `'span'` (widgets are
* usually inline). Applied only when the renderer is first created; later
* renders with the same `key` keep the original tag.
*/
as?: string
/**
* Applied only when the renderer is first created; later renders with the
* same `key` keep the original class.
*/
className?: string
}
const WIDGET_CACHE = Symbol('tiptapReactWidgetCache')
/**
* Renders a React component into a ProseMirror widget decoration.
* Reuses Tiptap's `ReactRenderer` so the component lives in the editor's
* React tree (context and hooks work as usual). Use a stable `key` for
* stateful widgets.
* @example
* addDecorations() {
* return {
* create: ({ editor, state }) =>
* findMatches(state.doc).map(match =>
* ReactWidgetRenderer(MyWidget, {
* editor, pos: match.pos, key: `match-${match.id}`,
* props: { label: match.label },
* }),
* ),
* }
* }
*/
export function ReactWidgetRenderer<P extends Record<string, any> = object>(
component: ComponentType<P & ReactWidgetDecorationProps>,
options: ReactWidgetRendererOptions<P>,
): WidgetDecoration {
const { editor, props = {} as P, as = 'span', className } = options
return createWidgetDecoration<ReactRenderer>({
// Forwards editor, pos, key and the ProseMirror widget options unchanged.
...options,
props,
cacheKey: WIDGET_CACHE,
context: getPos => ({ editor, getPos }),
create: renderProps =>
new ReactRenderer(component, {
editor,
as,
className,
props: renderProps,
}),
materialize: renderer => {
// Re-register the portal on every materialization so the editor's
// content component is available even on late first renders.
renderer.render()
return renderer.element
},
})
}

View File

@@ -0,0 +1,232 @@
import type { Editor } from '@tiptap/core'
import type { ReactNode } from 'react'
import { createContext, useContext, useMemo } from 'react'
import { EditorContext } from './Context.js'
import type { EditorContentProps, EditorStateSnapshot } from './index.js'
import { EditorContent, useEditorState } from './index.js'
/**
* The shape of the React context used by the `<Tiptap />` components.
*
* The editor instance is always available when using the default `useEditor`
* configuration. For SSR scenarios where `immediatelyRender: false` is used,
* consider using the legacy `EditorProvider` pattern instead.
*/
export type TiptapContextType = {
/** The Tiptap editor instance. */
editor: Editor
}
/**
* React context that stores the current editor instance.
*
* Use `useTiptap()` to read from this context in child components.
*/
export const TiptapContext = createContext<TiptapContextType>({
get editor(): Editor {
throw new Error('useTiptap must be used within a <Tiptap> provider')
},
})
TiptapContext.displayName = 'TiptapContext'
/**
* Hook to read the Tiptap context and access the editor instance.
*
* This is a small convenience wrapper around `useContext(TiptapContext)`.
* The editor is always available when used within a `<Tiptap>` provider.
*
* @returns The current `TiptapContextType` value from the provider.
*
* @example
* ```tsx
* import { useTiptap } from '@tiptap/react'
*
* function Toolbar() {
* const { editor } = useTiptap()
*
* return (
* <button onClick={() => editor.chain().focus().toggleBold().run()}>
* Bold
* </button>
* )
* }
* ```
*/
export const useTiptap = () => useContext(TiptapContext)
/**
* Select a slice of the editor state using the context-provided editor.
*
* This is a thin wrapper around `useEditorState` that reads the `editor`
* instance from `useTiptap()` so callers don't have to pass it manually.
*
* @typeParam TSelectorResult - The type returned by the selector.
* @param selector - Function that receives the editor state snapshot and
* returns the piece of state you want to subscribe to.
* @param equalityFn - Optional function to compare previous/next selected
* values and avoid unnecessary updates.
* @returns The selected slice of the editor state.
*
* @example
* ```tsx
* function WordCount() {
* const wordCount = useTiptapState(state => {
* const text = state.editor.state.doc.textContent
* return text.split(/\s+/).filter(Boolean).length
* })
*
* return <span>{wordCount} words</span>
* }
* ```
*/
export function useTiptapState<TSelectorResult>(
selector: (context: EditorStateSnapshot<Editor>) => TSelectorResult,
equalityFn?: (a: TSelectorResult, b: TSelectorResult | null) => boolean,
) {
const { editor } = useTiptap()
return useEditorState({
editor,
selector,
equalityFn,
})
}
export type TiptapWrapperEditorInstanceProps =
| {
/**
* The editor instance to provide to child components.
* Use `useEditor()` to create this instance.
*/
editor: Editor
}
| {
/**
* @deprecated Use `editor` instead. Will be removed in the next major version.
*/
instance: Editor
}
/**
* Props for the `Tiptap` root/provider component.
*/
export type TiptapWrapperProps = TiptapWrapperEditorInstanceProps & {
children: ReactNode
}
/**
* Top-level provider component that makes the editor instance available via
* React context to all child components.
*
* This component also provides backwards compatibility with the legacy
* `EditorContext`, so components using `useCurrentEditor()` will work
* inside a `<Tiptap>` provider.
*
* @param props - Component props.
* @returns A context provider element wrapping `children`.
*
* @example
* ```tsx
* import { Tiptap, useEditor } from '@tiptap/react'
*
* function App() {
* const editor = useEditor({ extensions: [...] })
*
* return (
* <Tiptap editor={editor}>
* <Toolbar />
* <Tiptap.Content />
* </Tiptap>
* )
* }
* ```
*/
export function TiptapWrapper({ children, ...props }: TiptapWrapperProps) {
const resolvedEditor = 'editor' in props ? props.editor : props.instance
if (!resolvedEditor) {
throw new Error('Tiptap: An editor instance is required. Pass a non-null `editor` prop.')
}
const tiptapContextValue = useMemo<TiptapContextType>(
() => ({ editor: resolvedEditor }),
[resolvedEditor],
)
// Provide backwards compatibility with the legacy EditorContext
// so components using useCurrentEditor() work inside <Tiptap>
const legacyContextValue = useMemo(() => ({ editor: resolvedEditor }), [resolvedEditor])
return (
<EditorContext.Provider value={legacyContextValue}>
<TiptapContext.Provider value={tiptapContextValue}>{children}</TiptapContext.Provider>
</EditorContext.Provider>
)
}
TiptapWrapper.displayName = 'Tiptap'
/**
* Convenience component that renders `EditorContent` using the context-provided
* editor instance. Use this instead of manually passing the `editor` prop.
*
* @param props - All `EditorContent` props except `editor` and `ref`.
* @returns An `EditorContent` element bound to the context editor.
*
* @example
* ```tsx
* // inside a Tiptap provider
* <Tiptap.Content className="editor" />
* ```
*/
export function TiptapContent({ ...rest }: Omit<EditorContentProps, 'editor' | 'ref'>) {
const { editor } = useTiptap()
return <EditorContent editor={editor} {...rest} />
}
TiptapContent.displayName = 'Tiptap.Content'
/**
* Root `Tiptap` component. Use it as the provider for all child components.
*
* The exported object includes the `Content` subcomponent for rendering the
* editor content area.
*
* This component provides both the new `TiptapContext` (accessed via `useTiptap()`)
* and the legacy `EditorContext` (accessed via `useCurrentEditor()`) for
* backwards compatibility.
*
* For bubble menus and floating menus, import them separately from
* `@tiptap/react/menus` to keep floating-ui as an optional dependency.
*
* @example
* ```tsx
* import { Tiptap, useEditor } from '@tiptap/react'
* import { BubbleMenu } from '@tiptap/react/menus'
*
* function App() {
* const editor = useEditor({ extensions: [...] })
*
* return (
* <Tiptap editor={editor}>
* <Tiptap.Content />
* <BubbleMenu>
* <button onClick={() => editor.chain().focus().toggleBold().run()}>Bold</button>
* </BubbleMenu>
* </Tiptap>
* )
* }
* ```
*/
export const Tiptap = Object.assign(TiptapWrapper, {
/**
* The Tiptap Content component that renders the EditorContent with the editor instance from the context.
* @see TiptapContent
*/
Content: TiptapContent,
})
export default Tiptap

View File

@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { captureDOMSelection } from './captureDOMSelection.js'
const createContent = () => {
const oldParent = document.createElement('div')
const newParent = document.createElement('div')
const content = document.createElement('div')
const text = document.createTextNode('Alpha')
content.appendChild(text)
oldParent.appendChild(content)
document.body.append(oldParent, newParent)
return { content, newParent, text }
}
const selectInside = (node: Node, offset: number) => {
const selection = document.getSelection()!
selection.removeAllRanges()
const range = document.createRange()
range.setStart(node, offset)
range.collapse(true)
selection.addRange(range)
}
afterEach(() => {
document.getSelection()?.removeAllRanges()
document.body.innerHTML = ''
})
describe('captureDOMSelection', () => {
it('restores a selection that lives inside the element', () => {
const { content, newParent, text } = createContent()
selectInside(text, 3)
const restore = captureDOMSelection(content)
newParent.appendChild(content)
// jsdom keeps the selection on a move, browsers drop it. Fake that here.
document.getSelection()?.removeAllRanges()
restore?.()
const selection = document.getSelection()!
expect(restore).toBeTypeOf('function')
expect(selection.anchorNode).toBe(text)
expect(selection.anchorOffset).toBe(3)
})
it('returns null when there is no selection', () => {
const { content } = createContent()
document.getSelection()?.removeAllRanges()
expect(captureDOMSelection(content)).toBeNull()
})
it('returns null when the selection is outside of the element', () => {
const { content } = createContent()
const outside = document.createElement('div')
outside.appendChild(document.createTextNode('Beta'))
document.body.appendChild(outside)
selectInside(outside.firstChild!, 2)
expect(captureDOMSelection(content)).toBeNull()
})
})

View File

@@ -0,0 +1,35 @@
type SelectionRoot = Node & { getSelection?: () => Selection | null }
/**
* Saves the DOM selection inside `element`, returns a function to put it back.
* Browsers drop the selection when an element moves, so restore it after the move.
*/
export function captureDOMSelection(element: HTMLElement): (() => void) | null {
const root = element.getRootNode() as SelectionRoot
const selection =
typeof root.getSelection === 'function'
? root.getSelection()
: element.ownerDocument?.defaultView?.getSelection()
if (!selection || selection.rangeCount === 0) {
return null
}
const { anchorNode, anchorOffset, focusNode, focusOffset } = selection
if (!anchorNode || !focusNode) {
return null
}
if (!element.contains(anchorNode) || !element.contains(focusNode)) {
return null
}
return () => {
try {
selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset)
} catch {
// The saved nodes can be gone by now.
}
}
}

View File

@@ -0,0 +1,16 @@
'use client'
export * from './Context.js'
export * from './EditorContent.js'
export * from './NodeViewContent.js'
export * from './NodeViewWrapper.js'
export * from './ReactMarkViewRenderer.js'
export * from './ReactNodeViewRenderer.js'
export * from './ReactRenderer.js'
export * from './ReactWidgetRenderer.js'
export * from './Tiptap.js'
export * from './types.js'
export * from './useEditor.js'
export * from './useEditorState.js'
export * from './useReactNodeView.js'
export * from '@tiptap/core'

View File

@@ -0,0 +1,27 @@
import { isTextSelection } from '@tiptap/core'
import type { ResolvedPos } from '@tiptap/pm/model'
import type { Selection } from '@tiptap/pm/state'
function isSameAncestor($from: ResolvedPos, $to: ResolvedPos, depth: number): boolean {
return $from.node(depth) === $to.node(depth) && $from.before(depth) === $to.before(depth)
}
export function getTextSelectionAncestorPositions(selection: Selection): number[] {
if (!isTextSelection(selection)) {
return []
}
const { $from, $to } = selection
const positions: number[] = []
const maxDepth = Math.min($from.depth, $to.depth)
for (let depth = 1; depth <= maxDepth; depth += 1) {
if (!isSameAncestor($from, $to, depth)) {
break
}
positions.push($from.before(depth))
}
return positions
}

View File

@@ -0,0 +1,178 @@
import { render } from '@testing-library/react'
import React, { createRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { BubbleMenu } from './BubbleMenu.js'
const { bubbleMenuPluginMock } = vi.hoisted(() => ({
bubbleMenuPluginMock: vi.fn(() => ({ key: 'bubble-menu-plugin' })),
}))
vi.mock('@tiptap/extension-bubble-menu', () => ({
BubbleMenuPlugin: bubbleMenuPluginMock,
}))
function createEditor() {
const tr = {
setMeta: vi.fn(() => tr),
}
return {
isDestroyed: false,
registerPlugin: vi.fn(),
unregisterPlugin: vi.fn(),
view: {
dispatch: vi.fn(),
},
state: {
tr,
},
}
}
describe('BubbleMenu', () => {
beforeEach(() => {
bubbleMenuPluginMock.mockClear()
})
afterEach(() => {
document.body.innerHTML = ''
})
it('applies html props to the actual menu element', () => {
const editor = createEditor()
const ref = createRef<HTMLDivElement>()
const handleClick = vi.fn()
let lastEvent: any
const handleClickCapture = vi.fn()
const handleDoubleClick = vi.fn()
const initialProps = {
editor: editor as never,
className: 'bubble-menu',
'data-testid': 'menu-element',
'aria-label': 'Bubble menu',
style: { zIndex: 9999, marginTop: 8, position: 'relative' },
onClick: (event: unknown) => {
lastEvent = event
handleClick()
},
onClickCapture: handleClickCapture,
onDoubleClick: handleDoubleClick,
dangerouslySetInnerHTML: { __html: 'ignored' },
pluginKey: 'bubbleMenu',
tabIndex: 3,
ref,
children: React.createElement('button', { type: 'button' }, 'Menu action'),
} as any
const updatedProps = {
editor: editor as never,
className: 'bubble-menu-updated',
style: { zIndex: 1000, marginTop: 16, position: 'static' },
onClick: undefined,
onClickCapture: undefined,
onDoubleClick: undefined,
pluginKey: 'bubbleMenu',
tabIndex: undefined,
ref,
children: React.createElement('button', { type: 'button' }, 'Updated action'),
} as any
const { rerender, unmount } = render(React.createElement(BubbleMenu, initialProps))
expect(editor.registerPlugin).toHaveBeenCalledTimes(1)
expect(bubbleMenuPluginMock).toHaveBeenCalledTimes(1)
const [{ element }] = bubbleMenuPluginMock.mock.calls[0] as unknown as [
{ element: HTMLDivElement },
]
expect(element).toBeInstanceOf(HTMLDivElement)
expect(element).toBe(ref.current)
expect(element.className).toBe('bubble-menu')
expect(element.getAttribute('data-testid')).toBe('menu-element')
expect(element.getAttribute('aria-label')).toBe('Bubble menu')
expect(element.tabIndex).toBe(3)
expect(element.style.zIndex).toBe('9999')
expect(element.style.marginTop).toBe('8px')
expect(element.style.position).toBe('absolute')
expect(element.getAttribute('dangerouslySetInnerHTML')).toBeNull()
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
element.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
expect(handleClick).toHaveBeenCalledTimes(2)
expect(handleDoubleClick).toHaveBeenCalledTimes(1)
expect(handleClickCapture).toHaveBeenCalledTimes(2)
expect(lastEvent.nativeEvent).toBeInstanceOf(MouseEvent)
expect(lastEvent.currentTarget).toBe(element)
expect(lastEvent.target).toBeInstanceOf(Element)
expect(typeof lastEvent.persist).toBe('function')
expect(element.textContent).toContain('Menu action')
rerender(React.createElement(BubbleMenu, updatedProps))
expect(element.className).toBe('bubble-menu-updated')
expect(element.getAttribute('data-testid')).toBeNull()
expect(element.getAttribute('aria-label')).toBeNull()
expect(element.tabIndex).toBe(-1)
expect(element.style.zIndex).toBe('1000')
expect(element.style.marginTop).toBe('16px')
expect(element.style.position).toBe('absolute')
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
element.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
expect(handleClick).toHaveBeenCalledTimes(2)
expect(handleDoubleClick).toHaveBeenCalledTimes(1)
expect(handleClickCapture).toHaveBeenCalledTimes(2)
expect(element.textContent).toContain('Updated action')
unmount()
expect(editor.unregisterPlugin).toHaveBeenCalledWith('bubbleMenu')
})
it('creates unique plugin keys when none are provided', () => {
const editor = createEditor()
const shouldShowA = vi.fn(() => true)
const shouldShowB = vi.fn(() => false)
const { unmount } = render(
React.createElement(
React.Fragment,
null,
React.createElement(BubbleMenu, {
editor: editor as never,
shouldShow: shouldShowA,
children: React.createElement('button', { type: 'button' }, 'First'),
} as any),
React.createElement(BubbleMenu, {
editor: editor as never,
shouldShow: shouldShowB,
children: React.createElement('button', { type: 'button' }, 'Second'),
} as any),
),
)
expect(bubbleMenuPluginMock).toHaveBeenCalledTimes(2)
const pluginCalls = bubbleMenuPluginMock.mock.calls as unknown as Array<
[{ pluginKey: unknown }]
>
const firstPluginKey = pluginCalls[0][0].pluginKey
const secondPluginKey = pluginCalls[1][0].pluginKey
expect(firstPluginKey).toBeDefined()
expect(secondPluginKey).toBeDefined()
expect(firstPluginKey).not.toBe(secondPluginKey)
unmount()
const unregisterCalls = editor.unregisterPlugin.mock.calls as unknown as Array<[unknown]>
expect(unregisterCalls.some(([key]) => key === firstPluginKey)).toBe(true)
expect(unregisterCalls.some(([key]) => key === secondPluginKey)).toBe(true)
})
})

View File

@@ -0,0 +1,160 @@
import { type BubbleMenuPluginProps, BubbleMenuPlugin } from '@tiptap/extension-bubble-menu'
import type { PluginKey } from '@tiptap/pm/state'
import { useCurrentEditor } from '@tiptap/react'
import React, { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { getAutoPluginKey } from './getAutoPluginKey.js'
import { useMenuElementProps } from './useMenuElementProps.js'
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
export type BubbleMenuProps = Optional<
Omit<Optional<BubbleMenuPluginProps, 'pluginKey'>, 'element'>,
'editor'
> &
React.HTMLAttributes<HTMLDivElement>
export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
(
{
pluginKey,
editor,
updateDelay,
resizeDelay,
appendTo,
shouldShow = null,
getReferencedVirtualElement,
options,
children,
...restProps
},
ref,
) => {
const menuEl = useRef(document.createElement('div'))
const resolvedPluginKey = useRef<PluginKey | string>(
getAutoPluginKey(pluginKey, 'bubbleMenu'),
).current
useMenuElementProps(menuEl.current, restProps)
if (typeof ref === 'function') {
ref(menuEl.current)
} else if (ref) {
ref.current = menuEl.current
}
const { editor: currentEditor } = useCurrentEditor()
/**
* The editor instance where the bubble menu plugin will be registered.
*/
const pluginEditor = editor || currentEditor
// Creating a useMemo would be more computationally expensive than just
// re-creating this object on every render.
const bubbleMenuPluginProps: Omit<BubbleMenuPluginProps, 'editor' | 'element'> = {
updateDelay,
resizeDelay,
appendTo,
pluginKey: resolvedPluginKey,
shouldShow,
getReferencedVirtualElement,
options,
}
/**
* The props for the bubble menu plugin. They are accessed inside a ref to
* avoid running the useEffect hook and re-registering the plugin when the
* props change.
*/
const bubbleMenuPluginPropsRef = useRef(bubbleMenuPluginProps)
bubbleMenuPluginPropsRef.current = bubbleMenuPluginProps
/**
* Track whether the plugin has been initialized, so we only send updates
* after the initial registration.
*/
const [pluginInitialized, setPluginInitialized] = useState(false)
/**
* Track whether we need to skip the first options update dispatch.
* This prevents unnecessary updates right after plugin initialization.
*/
const skipFirstUpdateRef = useRef(true)
useEffect(() => {
if (pluginEditor?.isDestroyed) {
return
}
if (!pluginEditor) {
console.warn(
'BubbleMenu component is not rendered inside of an editor component or does not have editor prop.',
)
return
}
const bubbleMenuElement = menuEl.current
bubbleMenuElement.style.visibility = 'hidden'
bubbleMenuElement.style.position = 'absolute'
const plugin = BubbleMenuPlugin({
...bubbleMenuPluginPropsRef.current,
editor: pluginEditor,
element: bubbleMenuElement,
})
pluginEditor.registerPlugin(plugin)
const createdPluginKey = bubbleMenuPluginPropsRef.current.pluginKey
skipFirstUpdateRef.current = true
setPluginInitialized(true)
return () => {
setPluginInitialized(false)
pluginEditor.unregisterPlugin(createdPluginKey)
window.requestAnimationFrame(() => {
if (bubbleMenuElement.parentNode) {
bubbleMenuElement.parentNode.removeChild(bubbleMenuElement)
}
})
}
}, [pluginEditor])
/**
* Update the plugin options when props change after the plugin has been initialized.
* This allows dynamic updates to options like scrollTarget without re-registering the entire plugin.
*/
useEffect(() => {
if (!pluginInitialized || !pluginEditor || pluginEditor.isDestroyed) {
return
}
// Skip the first update right after initialization since the plugin was just created with these options
if (skipFirstUpdateRef.current) {
skipFirstUpdateRef.current = false
return
}
pluginEditor.view.dispatch(
pluginEditor.state.tr.setMeta(resolvedPluginKey, {
type: 'updateOptions',
options: bubbleMenuPluginPropsRef.current,
}),
)
}, [
pluginInitialized,
pluginEditor,
updateDelay,
resizeDelay,
shouldShow,
options,
appendTo,
getReferencedVirtualElement,
resolvedPluginKey,
])
return createPortal(children, menuEl.current)
},
)

View File

@@ -0,0 +1,168 @@
import { render } from '@testing-library/react'
import React, { createRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { FloatingMenu } from './FloatingMenu.js'
const { floatingMenuPluginMock } = vi.hoisted(() => ({
floatingMenuPluginMock: vi.fn(() => ({ key: 'floating-menu-plugin' })),
}))
vi.mock('@tiptap/extension-floating-menu', () => ({
FloatingMenuPlugin: floatingMenuPluginMock,
}))
function createEditor() {
const tr = {
setMeta: vi.fn(() => tr),
}
return {
isDestroyed: false,
registerPlugin: vi.fn(),
unregisterPlugin: vi.fn(),
view: {
dispatch: vi.fn(),
},
state: {
tr,
},
}
}
describe('FloatingMenu', () => {
beforeEach(() => {
floatingMenuPluginMock.mockClear()
})
afterEach(() => {
document.body.innerHTML = ''
})
it('applies html props to the actual menu element', () => {
const editor = createEditor()
const ref = createRef<HTMLDivElement>()
const handleClick = vi.fn()
const handleClickCapture = vi.fn()
const handleDoubleClick = vi.fn()
const initialProps = {
editor: editor as never,
className: 'floating-menu',
'data-testid': 'floating-element',
'aria-label': 'Floating menu',
style: { zIndex: 8888, marginTop: 12, position: 'relative' },
onClick: handleClick,
onClickCapture: handleClickCapture,
onDoubleClick: handleDoubleClick,
pluginKey: 'floatingMenu',
tabIndex: 5,
ref,
children: React.createElement('button', { type: 'button' }, 'Floating action'),
} as any
const updatedProps = {
editor: editor as never,
className: 'floating-menu-updated',
style: { zIndex: 7777, marginTop: 20, position: 'static' },
onClick: undefined,
onClickCapture: undefined,
onDoubleClick: undefined,
pluginKey: 'floatingMenu',
tabIndex: undefined,
ref,
children: React.createElement('button', { type: 'button' }, 'Updated floating action'),
} as any
const { rerender, unmount } = render(React.createElement(FloatingMenu, initialProps))
expect(editor.registerPlugin).toHaveBeenCalledTimes(1)
expect(floatingMenuPluginMock).toHaveBeenCalledTimes(1)
const [{ element }] = floatingMenuPluginMock.mock.calls[0] as unknown as [
{ element: HTMLDivElement },
]
expect(element).toBeInstanceOf(HTMLDivElement)
expect(element).toBe(ref.current)
expect(element.className).toBe('floating-menu')
expect(element.getAttribute('data-testid')).toBe('floating-element')
expect(element.getAttribute('aria-label')).toBe('Floating menu')
expect(element.tabIndex).toBe(5)
expect(element.style.zIndex).toBe('8888')
expect(element.style.marginTop).toBe('12px')
expect(element.style.position).toBe('absolute')
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
element.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
expect(handleClick).toHaveBeenCalledTimes(2)
expect(handleDoubleClick).toHaveBeenCalledTimes(1)
expect(handleClickCapture).toHaveBeenCalledTimes(2)
expect(element.textContent).toContain('Floating action')
rerender(React.createElement(FloatingMenu, updatedProps))
expect(element.className).toBe('floating-menu-updated')
expect(element.getAttribute('data-testid')).toBeNull()
expect(element.getAttribute('aria-label')).toBeNull()
expect(element.tabIndex).toBe(-1)
expect(element.style.zIndex).toBe('7777')
expect(element.style.marginTop).toBe('20px')
expect(element.style.position).toBe('absolute')
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
element.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
expect(handleClick).toHaveBeenCalledTimes(2)
expect(handleDoubleClick).toHaveBeenCalledTimes(1)
expect(handleClickCapture).toHaveBeenCalledTimes(2)
expect(element.textContent).toContain('Updated floating action')
unmount()
expect(editor.unregisterPlugin).toHaveBeenCalledWith('floatingMenu')
})
it('creates unique plugin keys when none are provided', () => {
const editor = createEditor()
const shouldShowA = vi.fn(() => true)
const shouldShowB = vi.fn(() => false)
const { unmount } = render(
React.createElement(
React.Fragment,
null,
React.createElement(FloatingMenu, {
editor: editor as never,
shouldShow: shouldShowA,
children: React.createElement('button', { type: 'button' }, 'First'),
} as any),
React.createElement(FloatingMenu, {
editor: editor as never,
shouldShow: shouldShowB,
children: React.createElement('button', { type: 'button' }, 'Second'),
} as any),
),
)
expect(floatingMenuPluginMock).toHaveBeenCalledTimes(2)
const pluginCalls = floatingMenuPluginMock.mock.calls as unknown as Array<
[{ pluginKey: unknown }]
>
const firstPluginKey = pluginCalls[0][0].pluginKey
const secondPluginKey = pluginCalls[1][0].pluginKey
expect(firstPluginKey).toBeDefined()
expect(secondPluginKey).toBeDefined()
expect(firstPluginKey).not.toBe(secondPluginKey)
unmount()
const unregisterCalls = editor.unregisterPlugin.mock.calls as unknown as Array<[unknown]>
expect(unregisterCalls.some(([key]) => key === firstPluginKey)).toBe(true)
expect(unregisterCalls.some(([key]) => key === secondPluginKey)).toBe(true)
})
})

View File

@@ -0,0 +1,161 @@
import type { FloatingMenuPluginProps } from '@tiptap/extension-floating-menu'
import { FloatingMenuPlugin } from '@tiptap/extension-floating-menu'
import type { PluginKey } from '@tiptap/pm/state'
import { useCurrentEditor } from '@tiptap/react'
import React, { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { getAutoPluginKey } from './getAutoPluginKey.js'
import { useMenuElementProps } from './useMenuElementProps.js'
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
export type FloatingMenuProps = Omit<
Optional<FloatingMenuPluginProps, 'pluginKey'>,
'element' | 'editor'
> & {
editor: FloatingMenuPluginProps['editor'] | null
options?: FloatingMenuPluginProps['options']
} & React.HTMLAttributes<HTMLDivElement>
export const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(
(
{
pluginKey,
editor,
updateDelay,
resizeDelay,
appendTo,
shouldShow = null,
options,
children,
...restProps
},
ref,
) => {
const menuEl = useRef(document.createElement('div'))
const resolvedPluginKey = useRef<PluginKey | string>(
getAutoPluginKey(pluginKey, 'floatingMenu'),
).current
useMenuElementProps(menuEl.current, restProps)
if (typeof ref === 'function') {
ref(menuEl.current)
} else if (ref) {
ref.current = menuEl.current
}
const { editor: currentEditor } = useCurrentEditor()
/**
* The editor instance where the floating menu plugin will be registered.
*/
const pluginEditor = editor || currentEditor
// Creating a useMemo would be more computationally expensive than just
// re-creating this object on every render.
const floatingMenuPluginProps: Omit<FloatingMenuPluginProps, 'editor' | 'element'> = {
updateDelay,
resizeDelay,
appendTo,
pluginKey: resolvedPluginKey,
shouldShow,
options,
}
/**
* The props for the floating menu plugin. They are accessed inside a ref to
* avoid running the useEffect hook and re-registering the plugin when the
* props change.
*/
const floatingMenuPluginPropsRef = useRef(floatingMenuPluginProps)
floatingMenuPluginPropsRef.current = floatingMenuPluginProps
/**
* Track whether the plugin has been initialized, so we only send updates
* after the initial registration.
*/
const [pluginInitialized, setPluginInitialized] = useState(false)
/**
* Track whether we need to skip the first options update dispatch.
* This prevents unnecessary updates right after plugin initialization.
*/
const skipFirstUpdateRef = useRef(true)
useEffect(() => {
if (pluginEditor?.isDestroyed) {
return
}
if (!pluginEditor) {
console.warn(
'FloatingMenu component is not rendered inside of an editor component or does not have editor prop.',
)
return
}
const floatingMenuElement = menuEl.current
floatingMenuElement.style.visibility = 'hidden'
floatingMenuElement.style.position = 'absolute'
const plugin = FloatingMenuPlugin({
...floatingMenuPluginPropsRef.current,
editor: pluginEditor,
element: floatingMenuElement,
})
pluginEditor.registerPlugin(plugin)
const createdPluginKey = floatingMenuPluginPropsRef.current.pluginKey
skipFirstUpdateRef.current = true
setPluginInitialized(true)
return () => {
setPluginInitialized(false)
pluginEditor.unregisterPlugin(createdPluginKey)
window.requestAnimationFrame(() => {
if (floatingMenuElement.parentNode) {
floatingMenuElement.parentNode.removeChild(floatingMenuElement)
}
})
}
}, [pluginEditor])
/**
* Update the plugin options when props change after the plugin has been initialized.
* This allows dynamic updates to options like scrollTarget without re-registering the entire plugin.
*/
useEffect(() => {
if (!pluginInitialized || !pluginEditor || pluginEditor.isDestroyed) {
return
}
// Skip the first update right after initialization since the plugin was just created with these options
if (skipFirstUpdateRef.current) {
skipFirstUpdateRef.current = false
return
}
pluginEditor.view.dispatch(
pluginEditor.state.tr.setMeta(resolvedPluginKey, {
type: 'updateOptions',
options: floatingMenuPluginPropsRef.current,
}),
)
}, [
pluginInitialized,
pluginEditor,
updateDelay,
resizeDelay,
shouldShow,
options,
appendTo,
resolvedPluginKey,
])
return createPortal(children, menuEl.current)
},
)

View File

@@ -0,0 +1,5 @@
import { PluginKey } from '@tiptap/pm/state'
export function getAutoPluginKey(pluginKey: PluginKey | string | undefined, defaultName: string) {
return pluginKey ?? new PluginKey(defaultName)
}

View File

@@ -0,0 +1,4 @@
'use client'
export * from './BubbleMenu.js'
export * from './FloatingMenu.js'

View File

@@ -0,0 +1,369 @@
import type { CSSProperties, HTMLAttributes } from 'react'
import { useEffect, useLayoutEffect, useRef } from 'react'
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
type MenuElementProps = HTMLAttributes<HTMLDivElement>
type MenuSyntheticEvent = Event & {
nativeEvent: Event
currentTarget: HTMLDivElement
target: EventTarget | null
persist: () => void
isDefaultPrevented: () => boolean
isPropagationStopped: () => boolean
}
type MenuEventListener = (event: MenuSyntheticEvent) => void
type MenuNativeListener = (event: Event) => void
type MenuEventListenerOptions = {
capture?: boolean
}
type EventListenerEntry = {
eventName: string
listener: MenuNativeListener
options?: MenuEventListenerOptions
}
const PLUGIN_MANAGED_STYLE_PROPERTIES = new Set([
'left',
'opacity',
'position',
'top',
'visibility',
'width',
])
const UNITLESS_STYLE_PROPERTIES = new Set([
'animationIterationCount',
'aspectRatio',
'borderImageOutset',
'borderImageSlice',
'borderImageWidth',
'columnCount',
'columns',
'fillOpacity',
'flex',
'flexGrow',
'flexShrink',
'fontWeight',
'gridArea',
'gridColumn',
'gridColumnEnd',
'gridColumnStart',
'gridRow',
'gridRowEnd',
'gridRowStart',
'lineClamp',
'lineHeight',
'opacity',
'order',
'orphans',
'scale',
'stopOpacity',
'strokeDasharray',
'strokeDashoffset',
'strokeMiterlimit',
'strokeOpacity',
'strokeWidth',
'tabSize',
'widows',
'zIndex',
'zoom',
])
const ATTRIBUTE_EXCLUSIONS = new Set(['children', 'className', 'style'])
const DIRECT_PROPERTY_KEYS = new Set(['tabIndex'])
const FORWARDED_ATTRIBUTE_KEYS = new Set([
'accessKey',
'autoCapitalize',
'contentEditable',
'contextMenu',
'dir',
'draggable',
'enterKeyHint',
'hidden',
'id',
'lang',
'nonce',
'role',
'slot',
'spellCheck',
'tabIndex',
'title',
'translate',
])
const SPECIAL_EVENT_NAMES: Record<string, string> = {
Blur: 'focusout',
DoubleClick: 'dblclick',
Focus: 'focusin',
MouseEnter: 'mouseenter',
MouseLeave: 'mouseleave',
}
function isEventProp(key: string, value: unknown): value is MenuEventListener {
return /^on[A-Z]/.test(key) && typeof value === 'function'
}
function toAttributeName(key: string) {
if (key.startsWith('aria-') || key.startsWith('data-')) {
return key
}
return key
}
function isForwardedAttributeKey(key: string) {
return key.startsWith('aria-') || key.startsWith('data-') || FORWARDED_ATTRIBUTE_KEYS.has(key)
}
function toStylePropertyName(key: string) {
if (key.startsWith('--')) {
return key
}
return key.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)
}
function toEventConfig(key: string) {
const useCapture = key.endsWith('Capture')
const baseKey = useCapture ? key.slice(0, -7) : key
const reactEventName = baseKey.slice(2)
const eventName = SPECIAL_EVENT_NAMES[reactEventName] ?? reactEventName.toLowerCase()
return {
eventName,
options: useCapture ? { capture: true } : undefined,
}
}
function createSyntheticEvent(element: HTMLDivElement, nativeEvent: Event): MenuSyntheticEvent {
let defaultPrevented = nativeEvent.defaultPrevented
let propagationStopped = false
const syntheticEvent = Object.create(nativeEvent)
Object.defineProperties(syntheticEvent, {
nativeEvent: { value: nativeEvent },
currentTarget: { value: element },
target: { value: nativeEvent.target },
persist: { value: () => undefined },
isDefaultPrevented: { value: () => defaultPrevented },
isPropagationStopped: { value: () => propagationStopped },
preventDefault: {
value: () => {
defaultPrevented = true
nativeEvent.preventDefault()
},
},
stopPropagation: {
value: () => {
propagationStopped = true
nativeEvent.stopPropagation()
},
},
})
return syntheticEvent as MenuSyntheticEvent
}
function isDirectPropertyKey(key: string) {
return DIRECT_PROPERTY_KEYS.has(key)
}
function setDirectProperty(element: HTMLDivElement, key: string, value: unknown) {
if (key === 'tabIndex') {
element.tabIndex = Number(value)
return
}
;(element as unknown as Record<string, unknown>)[key] = value
}
function clearDirectProperty(element: HTMLDivElement, key: string) {
if (key === 'tabIndex') {
element.removeAttribute('tabindex')
return
}
const propertyValue = (element as unknown as Record<string, unknown>)[key]
if (typeof propertyValue === 'boolean') {
;(element as unknown as Record<string, unknown>)[key] = false
return
}
if (typeof propertyValue === 'number') {
;(element as unknown as Record<string, unknown>)[key] = 0
return
}
;(element as unknown as Record<string, unknown>)[key] = ''
}
function toStyleValue(styleName: string, value: string | number) {
if (
typeof value !== 'number' ||
value === 0 ||
styleName.startsWith('--') ||
UNITLESS_STYLE_PROPERTIES.has(styleName)
) {
return String(value)
}
return `${value}px`
}
function removeStyleProperty(element: HTMLDivElement, styleName: string) {
if (PLUGIN_MANAGED_STYLE_PROPERTIES.has(styleName)) {
return
}
element.style.removeProperty(toStylePropertyName(styleName))
}
function applyStyleProperty(element: HTMLDivElement, styleName: string, value: string | number) {
if (PLUGIN_MANAGED_STYLE_PROPERTIES.has(styleName)) {
return
}
element.style.setProperty(toStylePropertyName(styleName), toStyleValue(styleName, value))
}
function syncAttributes(
element: HTMLDivElement,
prevProps: MenuElementProps,
nextProps: MenuElementProps,
) {
const allKeys = new Set([...Object.keys(prevProps), ...Object.keys(nextProps)])
allKeys.forEach(key => {
if (
ATTRIBUTE_EXCLUSIONS.has(key) ||
!isForwardedAttributeKey(key) ||
isEventProp(key, prevProps[key as keyof MenuElementProps]) ||
isEventProp(key, nextProps[key as keyof MenuElementProps])
) {
return
}
const prevValue = prevProps[key as keyof MenuElementProps]
const nextValue = nextProps[key as keyof MenuElementProps]
if (prevValue === nextValue) {
return
}
const attributeName = toAttributeName(key)
if (nextValue == null || nextValue === false) {
if (isDirectPropertyKey(key)) {
clearDirectProperty(element, key)
}
element.removeAttribute(attributeName)
return
}
if (nextValue === true) {
if (isDirectPropertyKey(key)) {
setDirectProperty(element, key, true)
}
element.setAttribute(attributeName, '')
return
}
if (isDirectPropertyKey(key)) {
setDirectProperty(element, key, nextValue)
return
}
element.setAttribute(attributeName, String(nextValue))
})
}
function syncClassName(element: HTMLDivElement, prevClassName?: string, nextClassName?: string) {
if (prevClassName === nextClassName) {
return
}
if (nextClassName) {
element.className = nextClassName
return
}
element.removeAttribute('class')
}
function syncStyles(
element: HTMLDivElement,
prevStyle: CSSProperties | undefined,
nextStyle: CSSProperties | undefined,
) {
const previousStyle = prevStyle ?? {}
const currentStyle = nextStyle ?? {}
const allStyleNames = new Set([...Object.keys(previousStyle), ...Object.keys(currentStyle)])
allStyleNames.forEach(styleName => {
const prevValue = previousStyle[styleName as keyof CSSProperties]
const nextValue = currentStyle[styleName as keyof CSSProperties]
if (prevValue === nextValue) {
return
}
if (nextValue == null) {
removeStyleProperty(element, styleName)
return
}
applyStyleProperty(element, styleName, nextValue as string | number)
})
}
function syncEventListeners(
element: HTMLDivElement,
prevListeners: EventListenerEntry[],
nextProps: MenuElementProps,
) {
prevListeners.forEach(({ eventName, listener, options }) => {
element.removeEventListener(eventName, listener, options)
})
const nextListeners: EventListenerEntry[] = []
Object.entries(nextProps).forEach(([key, value]) => {
if (!isEventProp(key, value)) {
return
}
const { eventName, options } = toEventConfig(key)
const listener: MenuNativeListener = event => {
value(createSyntheticEvent(element, event))
}
element.addEventListener(eventName, listener, options)
nextListeners.push({ eventName, listener, options })
})
return nextListeners
}
export function useMenuElementProps(element: HTMLDivElement, props: MenuElementProps) {
const previousPropsRef = useRef<MenuElementProps>({})
const listenersRef = useRef<EventListenerEntry[]>([])
useIsomorphicLayoutEffect(() => {
const previousProps = previousPropsRef.current
syncClassName(element, previousProps.className, props.className)
syncStyles(element, previousProps.style, props.style)
syncAttributes(element, previousProps, props)
listenersRef.current = syncEventListeners(element, listenersRef.current, props)
previousPropsRef.current = props
return () => {
listenersRef.current.forEach(({ eventName, listener, options }) => {
element.removeEventListener(eventName, listener, options)
})
listenersRef.current = []
}
}, [element, props])
}

View File

@@ -0,0 +1,8 @@
import type { NodeViewProps as CoreNodeViewProps } from '@tiptap/core'
import type React from 'react'
export type ReactNodeViewProps<T = HTMLElement> = CoreNodeViewProps & {
ref: React.RefObject<T | null>
/** Whether a text selection is fully inside the node view. Always provided by ReactNodeViewRenderer. */
selectionInside?: boolean
}

View File

@@ -0,0 +1,379 @@
import { type EditorOptions, Editor } from '@tiptap/core'
import type { DependencyList, MutableRefObject } from 'react'
import { useDebugValue, useEffect, useRef, useState } from 'react'
import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js'
import { useEditorState } from './useEditorState.js'
// @ts-ignore
const isDev = process.env.NODE_ENV !== 'production'
const isSSR = typeof window === 'undefined'
const isNext = isSSR || Boolean(typeof window !== 'undefined' && (window as any).next)
/**
* The options for the `useEditor` hook.
*/
export type UseEditorOptions = Partial<EditorOptions> & {
/**
* Whether to render the editor on the first render.
* If client-side rendering, set this to `true`.
* If server-side rendering, set this to `false`.
* @default true
*/
immediatelyRender?: boolean
/**
* Whether to re-render the editor on each transaction.
* This is legacy behavior that will be removed in future versions.
* @default false
*/
shouldRerenderOnTransaction?: boolean
}
/**
* This class handles the creation, destruction, and re-creation of the editor instance.
*/
class EditorInstanceManager {
/**
* The current editor instance.
*/
private editor: Editor | null = null
/**
* The most recent options to apply to the editor.
*/
private options: MutableRefObject<UseEditorOptions>
/**
* The subscriptions to notify when the editor instance
* has been created or destroyed.
*/
private subscriptions = new Set<() => void>()
/**
* A timeout to destroy the editor if it was not mounted within a time frame.
*/
private scheduledDestructionTimeout: ReturnType<typeof setTimeout> | undefined
/**
* Whether the editor has been mounted.
*/
private isComponentMounted = false
/**
* The most recent dependencies array.
*/
private previousDeps: DependencyList | null = null
/**
* The unique instance ID. This is used to identify the editor instance. And will be re-generated for each new instance.
*/
public instanceId = ''
constructor(options: MutableRefObject<UseEditorOptions>) {
this.options = options
this.subscriptions = new Set<() => void>()
this.setEditor(this.getInitialEditor())
this.scheduleDestroy()
this.getEditor = this.getEditor.bind(this)
this.getServerSnapshot = this.getServerSnapshot.bind(this)
this.subscribe = this.subscribe.bind(this)
this.refreshEditorInstance = this.refreshEditorInstance.bind(this)
this.scheduleDestroy = this.scheduleDestroy.bind(this)
this.onRender = this.onRender.bind(this)
this.createEditor = this.createEditor.bind(this)
}
private setEditor(editor: Editor | null) {
this.editor = editor
this.instanceId = Math.random().toString(36).slice(2, 9)
// Notify all subscribers that the editor instance has been created
this.subscriptions.forEach(cb => cb())
}
private getInitialEditor() {
const explicit = this.options.current.immediatelyRender
let immediatelyRender = explicit ?? true
if (isSSR) {
if (immediatelyRender && isDev) {
console.warn(
'SSR detected. `immediatelyRender` has been set to false to avoid hydration mismatches',
)
}
immediatelyRender = false
} else if (isNext && explicit === undefined) {
immediatelyRender = false
if (isDev) {
console.warn(
'Next.js detected. `immediatelyRender` defaults to false to avoid hydration mismatches. Pass `immediatelyRender: true` explicitly if you are rendering the editor only on the client.',
)
}
}
return immediatelyRender ? this.createEditor() : null
}
/**
* Create a new editor instance. And attach event listeners.
*/
private createEditor(): Editor {
const optionsToApply: Partial<EditorOptions> = {
...this.options.current,
// Always call the most recent version of the callback function by default
onBeforeCreate: (...args) => this.options.current.onBeforeCreate?.(...args),
onBlur: (...args) => this.options.current.onBlur?.(...args),
onCreate: (...args) => this.options.current.onCreate?.(...args),
onDestroy: (...args) => this.options.current.onDestroy?.(...args),
onFocus: (...args) => this.options.current.onFocus?.(...args),
onSelectionUpdate: (...args) => this.options.current.onSelectionUpdate?.(...args),
onTransaction: (...args) => this.options.current.onTransaction?.(...args),
onUpdate: (...args) => this.options.current.onUpdate?.(...args),
onContentError: (...args) => this.options.current.onContentError?.(...args),
onDrop: (...args) => this.options.current.onDrop?.(...args),
onPaste: (...args) => this.options.current.onPaste?.(...args),
onDelete: (...args) => this.options.current.onDelete?.(...args),
onMount: (...args) => this.options.current.onMount?.(...args),
onUnmount: (...args) => this.options.current.onUnmount?.(...args),
}
const editor = new Editor(optionsToApply)
// no need to keep track of the event listeners, they will be removed when the editor is destroyed
return editor
}
/**
* Get the current editor instance.
*/
getEditor(): Editor | null {
return this.editor
}
/**
* Always disable the editor on the server-side.
*/
getServerSnapshot(): null {
return null
}
/**
* Subscribe to the editor instance's changes.
*/
subscribe(onStoreChange: () => void) {
this.subscriptions.add(onStoreChange)
return () => {
this.subscriptions.delete(onStoreChange)
}
}
static compareOptions(a: UseEditorOptions, b: UseEditorOptions) {
return (Object.keys(a) as (keyof UseEditorOptions)[]).every(key => {
if (
[
'onCreate',
'onBeforeCreate',
'onDestroy',
'onUpdate',
'onTransaction',
'onFocus',
'onBlur',
'onSelectionUpdate',
'onContentError',
'onDrop',
'onPaste',
].includes(key)
) {
// we don't want to compare callbacks, they are always different and only registered once
return true
}
// We often encourage putting extensions inlined in the options object, so we will do a slightly deeper comparison here
if (key === 'extensions' && a.extensions && b.extensions) {
if (a.extensions.length !== b.extensions.length) {
return false
}
return a.extensions.every((extension, index) => {
if (extension !== b.extensions?.[index]) {
return false
}
return true
})
}
if (a[key] !== b[key]) {
// if any of the options have changed, we should update the editor options
return false
}
return true
})
}
/**
* On each render, we will create, update, or destroy the editor instance.
* @param deps The dependencies to watch for changes
* @returns A cleanup function
*/
onRender(deps: DependencyList) {
// The returned callback will run on each render
return () => {
this.isComponentMounted = true
// Cleanup any scheduled destructions, since we are currently rendering
clearTimeout(this.scheduledDestructionTimeout)
if (this.editor && !this.editor.isDestroyed && deps.length === 0) {
// if the editor does exist & deps are empty, we don't need to re-initialize the editor generally
if (!EditorInstanceManager.compareOptions(this.options.current, this.editor.options)) {
// But, the options are different, so we need to update the editor options
// Still, this is faster than re-creating the editor
this.editor.setOptions({
...this.options.current,
editable: this.editor.isEditable,
})
}
} else {
// When the editor:
// - does not yet exist
// - is destroyed
// - the deps array changes
// We need to destroy the editor instance and re-initialize it
this.refreshEditorInstance(deps)
}
return () => {
this.isComponentMounted = false
this.scheduleDestroy()
}
}
}
/**
* Recreate the editor instance if the dependencies have changed.
*/
private refreshEditorInstance(deps: DependencyList) {
if (this.editor && !this.editor.isDestroyed) {
// Editor instance already exists
if (this.previousDeps === null) {
// If lastDeps has not yet been initialized, reuse the current editor instance
this.previousDeps = deps
return
}
const depsAreEqual =
this.previousDeps.length === deps.length &&
this.previousDeps.every((dep, index) => dep === deps[index])
if (depsAreEqual) {
// deps exist and are equal, no need to recreate
return
}
}
if (this.editor && !this.editor.isDestroyed) {
// Destroy the editor instance if it exists
this.editor.destroy()
}
this.setEditor(this.createEditor())
// Update the lastDeps to the current deps
this.previousDeps = deps
}
/**
* Schedule the destruction of the editor instance.
* This will only destroy the editor if it was not mounted on the next tick.
* This is to avoid destroying the editor instance when it's actually still mounted.
*/
private scheduleDestroy() {
const currentInstanceId = this.instanceId
const currentEditor = this.editor
// Wait two ticks to see if the component is still mounted
this.scheduledDestructionTimeout = setTimeout(() => {
if (this.isComponentMounted && this.instanceId === currentInstanceId) {
// If still mounted on the following tick, with the same instanceId, do not destroy the editor
if (currentEditor) {
// just re-apply options as they might have changed
currentEditor.setOptions(this.options.current)
}
return
}
if (currentEditor && !currentEditor.isDestroyed) {
currentEditor.destroy()
if (this.instanceId === currentInstanceId) {
this.setEditor(null)
}
}
// This allows the effect to run again between ticks
// which may save us from having to re-create the editor
}, 1)
}
}
/**
* This hook allows you to create an editor instance.
* @param options The editor options
* @param deps The dependencies to watch for changes
* @returns The editor instance
* @example const editor = useEditor({ extensions: [...] })
*/
export function useEditor(
options: UseEditorOptions & { immediatelyRender: false },
deps?: DependencyList,
): Editor | null
/**
* This hook allows you to create an editor instance.
* @param options The editor options
* @param deps The dependencies to watch for changes
* @returns The editor instance
* @example const editor = useEditor({ extensions: [...] })
*/
export function useEditor(options: UseEditorOptions, deps?: DependencyList): Editor
export function useEditor(
options: UseEditorOptions = {},
deps: DependencyList = [],
): Editor | null {
const mostRecentOptions = useRef(options)
mostRecentOptions.current = options
const [instanceManager] = useState(() => new EditorInstanceManager(mostRecentOptions))
const editor = useSyncExternalStore(
instanceManager.subscribe,
instanceManager.getEditor,
instanceManager.getServerSnapshot,
)
useDebugValue(editor)
// This effect will handle creating/updating the editor instance
// oxlint-disable-next-line react-hooks/exhaustive-deps
useEffect(instanceManager.onRender(deps))
// The default behavior is to re-render on each transaction
// This is legacy behavior that will be removed in future versions
useEditorState({
editor,
selector: ({ transactionNumber }) => {
if (
options.shouldRerenderOnTransaction === false ||
options.shouldRerenderOnTransaction === undefined
) {
// This will prevent the editor from re-rendering on each transaction
return null
}
// This will avoid re-rendering on the first transaction when `immediatelyRender` is set to `true`
if (options.immediatelyRender && transactionNumber === 0) {
return 0
}
return transactionNumber + 1
},
})
return editor
}

View File

@@ -0,0 +1,121 @@
import { act, render } from '@testing-library/react'
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { TextSelection } from '@tiptap/pm/state'
import React from 'react'
import { afterEach, describe, expect, it } from 'vite-plus/test'
import { useEditorState } from './useEditorState.js'
const createTestEditor = () => {
return new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
})
}
const EditableIndicator = ({ editor }: { editor: Editor }) => {
const isEditable = useEditorState({
editor,
selector: ctx => ctx.editor.isEditable,
})
return React.createElement('div', { 'data-testid': 'editable-state' }, String(isEditable))
}
const ContentIndicator = ({ editor }: { editor: Editor }) => {
const text = useEditorState({
editor,
selector: ctx => ctx.editor.getText(),
})
return React.createElement('div', { 'data-testid': 'content-state' }, text)
}
const TransactionCountIndicator = ({ editor }: { editor: Editor }) => {
const transactionNumber = useEditorState({
editor,
selector: ctx => ctx.transactionNumber,
})
return React.createElement('div', { 'data-testid': 'tx-number' }, String(transactionNumber))
}
describe('useEditorState', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('re-renders when editor.setEditable() toggles the editable state', () => {
const editor = createTestEditor()
const { getByTestId } = render(React.createElement(EditableIndicator, { editor }))
expect(getByTestId('editable-state').textContent).toBe('true')
act(() => {
editor.setEditable(false)
})
expect(getByTestId('editable-state').textContent).toBe('false')
act(() => {
editor.setEditable(true)
})
expect(getByTestId('editable-state').textContent).toBe('true')
editor.destroy()
})
it('still re-renders on a normal content-changing transaction (regression)', () => {
const editor = createTestEditor()
const { getByTestId } = render(React.createElement(ContentIndicator, { editor }))
expect(getByTestId('content-state').textContent).toBe('Hello')
act(() => {
editor.commands.setContent('<p>World</p>')
})
expect(getByTestId('content-state').textContent).toBe('World')
editor.destroy()
})
it('notifies once per document change (transaction + update are deduped)', () => {
const editor = createTestEditor()
const { getByTestId } = render(React.createElement(TransactionCountIndicator, { editor }))
const before = Number(getByTestId('tx-number').textContent)
act(() => {
// A single doc-changing transaction emits both `transaction` and `update`.
editor.view.dispatch(editor.state.tr.insertText('!'))
})
const after = Number(getByTestId('tx-number').textContent)
expect(after - before).toBe(1)
editor.destroy()
})
it('still notifies on selection-only transactions (which emit no update event)', () => {
const editor = createTestEditor()
const { getByTestId } = render(React.createElement(TransactionCountIndicator, { editor }))
const before = Number(getByTestId('tx-number').textContent)
act(() => {
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atStart(editor.state.doc)))
})
const after = Number(getByTestId('tx-number').textContent)
expect(after - before).toBe(1)
editor.destroy()
})
})

View File

@@ -0,0 +1,187 @@
import type { Editor } from '@tiptap/core'
import { deepEqual } from 'fast-equals'
import { useDebugValue, useEffect, useLayoutEffect, useState } from 'react'
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector.js'
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
export type EditorStateSnapshot<TEditor extends Editor | null = Editor | null> = {
editor: TEditor
transactionNumber: number
}
export type UseEditorStateOptions<
TSelectorResult,
TEditor extends Editor | null = Editor | null,
> = {
/**
* The editor instance.
*/
editor: TEditor
/**
* A selector function to determine the value to compare for re-rendering.
*/
selector: (context: EditorStateSnapshot<TEditor>) => TSelectorResult
/**
* A custom equality function to determine if the editor should re-render.
* @default `deepEqual` from `fast-deep-equal`
*/
equalityFn?: (a: TSelectorResult, b: TSelectorResult | null) => boolean
}
/**
* To synchronize the editor instance with the component state,
* we need to create a separate instance that is not affected by the component re-renders.
*/
class EditorStateManager<TEditor extends Editor | null = Editor | null> {
private transactionNumber = 0
private lastTransactionNumber = 0
private lastSnapshot: EditorStateSnapshot<TEditor>
private editor: TEditor
private subscribers = new Set<() => void>()
constructor(initialEditor: TEditor) {
this.editor = initialEditor
this.lastSnapshot = { editor: initialEditor, transactionNumber: 0 }
this.getSnapshot = this.getSnapshot.bind(this)
this.getServerSnapshot = this.getServerSnapshot.bind(this)
this.watch = this.watch.bind(this)
this.subscribe = this.subscribe.bind(this)
}
/**
* Get the current editor instance.
*/
getSnapshot(): EditorStateSnapshot<TEditor> {
if (this.transactionNumber === this.lastTransactionNumber) {
return this.lastSnapshot
}
this.lastTransactionNumber = this.transactionNumber
this.lastSnapshot = { editor: this.editor, transactionNumber: this.transactionNumber }
return this.lastSnapshot
}
/**
* Always disable the editor on the server-side.
*/
getServerSnapshot(): EditorStateSnapshot<null> {
return { editor: null, transactionNumber: 0 }
}
/**
* Subscribe to the editor instance's changes.
*/
subscribe(callback: () => void): () => void {
this.subscribers.add(callback)
return () => {
this.subscribers.delete(callback)
}
}
/**
* Watch the editor instance for changes.
*/
watch(nextEditor: Editor | null): undefined | (() => void) {
this.editor = nextEditor as TEditor
if (this.editor) {
/**
* This will force a re-render when the editor state changes.
* This is to support things like `editor.can().toggleBold()` in components that `useEditor`.
* This could be more efficient, but it's a good trade-off for now.
*/
// Changes can emit both events, so notify only once
let lastTransaction: unknown
const fn = (props?: { transaction?: unknown }) => {
if (props?.transaction !== undefined && props.transaction === lastTransaction) {
return
}
lastTransaction = props?.transaction
this.transactionNumber += 1
this.subscribers.forEach(callback => callback())
}
const currentEditor = this.editor
currentEditor.on('transaction', fn)
currentEditor.on('update', fn)
return () => {
currentEditor.off('transaction', fn)
currentEditor.off('update', fn)
}
}
return undefined
}
}
/**
* This hook allows you to watch for changes on the editor instance.
* It will allow you to select a part of the editor state and re-render the component when it changes.
* @example
* ```tsx
* const editor = useEditor({...options})
* const { currentSelection } = useEditorState({
* editor,
* selector: snapshot => ({ currentSelection: snapshot.editor.state.selection }),
* })
*/
export function useEditorState<TSelectorResult>(
options: UseEditorStateOptions<TSelectorResult, Editor>,
): TSelectorResult
/**
* This hook allows you to watch for changes on the editor instance.
* It will allow you to select a part of the editor state and re-render the component when it changes.
* @example
* ```tsx
* const editor = useEditor({...options})
* const { currentSelection } = useEditorState({
* editor,
* selector: snapshot => ({ currentSelection: snapshot.editor.state.selection }),
* })
*/
export function useEditorState<TSelectorResult>(
options: UseEditorStateOptions<TSelectorResult, Editor | null>,
): TSelectorResult | null
/**
* This hook allows you to watch for changes on the editor instance.
* It will allow you to select a part of the editor state and re-render the component when it changes.
* @example
* ```tsx
* const editor = useEditor({...options})
* const { currentSelection } = useEditorState({
* editor,
* selector: snapshot => ({ currentSelection: snapshot.editor.state.selection }),
* })
*/
export function useEditorState<TSelectorResult>(
options:
| UseEditorStateOptions<TSelectorResult, Editor>
| UseEditorStateOptions<TSelectorResult, Editor | null>,
): TSelectorResult | null {
const [editorStateManager] = useState(() => new EditorStateManager(options.editor))
// Using the `useSyncExternalStore` hook to sync the editor instance with the component state
const selectedState = useSyncExternalStoreWithSelector(
editorStateManager.subscribe,
editorStateManager.getSnapshot,
editorStateManager.getServerSnapshot,
options.selector as UseEditorStateOptions<TSelectorResult, Editor | null>['selector'],
options.equalityFn ?? deepEqual,
)
useIsomorphicLayoutEffect(() => {
return editorStateManager.watch(options.editor)
}, [options.editor, editorStateManager])
useDebugValue(selectedState)
return selectedState
}

View File

@@ -0,0 +1,38 @@
import type { ReactNode } from 'react'
import { createContext, createElement, 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<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)

View File

@@ -0,0 +1,26 @@
/**
* @vitest-environment node
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vite-plus/test'
// The package boundary must stay a client module so it can be imported from
// React Server Components without evaluating the class components inside.
const entries = ['../../src/index.ts', '../../src/menus/index.ts']
describe('use client boundary', () => {
it.each(entries)('%s starts with the "use client" directive', entry => {
const path = fileURLToPath(new URL(entry, import.meta.url))
// The directive must be the very first statement (only a BOM may precede
// it), otherwise some RSC tooling ignores it. Do not trim leading blanks.
const firstLine = readFileSync(path, 'utf8')
.replace(/^\uFEFF/, '')
.split('\n')[0]
.trimEnd()
expect(firstLine).toMatch(/^(['"])use client\1;?$/)
})
})

View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite-plus'
import { tsupCompatibleExtensions } from '../../pack.config.mjs'
const entries = ['src/index.ts', 'src/menus/index.ts']
export default defineConfig({
pack: entries.map(entry => ({
entry: [entry],
tsconfig: '../../tsconfig.build.json',
outDir: `dist${entry.replace('src', '').split('/').slice(0, -1).join('/')}`,
dts: { sourcemap: true },
sourcemap: true,
target: 'es2019',
format: ['esm', 'cjs'],
deps: { neverBundle: [/^[^./]/] },
outExtensions: tsupCompatibleExtensions,
})),
})