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
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:
3184
packages/react/CHANGELOG.md
Normal file
3184
packages/react/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/react/README.md
Normal file
18
packages/react/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/react
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/react)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/react)
|
||||
[](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).
|
||||
78
packages/react/package.json
Normal file
78
packages/react/package.json
Normal 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:^"
|
||||
}
|
||||
}
|
||||
62
packages/react/src/Context.tsx
Normal file
62
packages/react/src/Context.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
packages/react/src/Editor.ts
Normal file
16
packages/react/src/Editor.ts
Normal 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>
|
||||
}
|
||||
39
packages/react/src/EditorContent.test.ts
Normal file
39
packages/react/src/EditorContent.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
214
packages/react/src/EditorContent.tsx
Normal file
214
packages/react/src/EditorContent.tsx
Normal 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)
|
||||
30
packages/react/src/NodeViewContent.tsx
Normal file
30
packages/react/src/NodeViewContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
packages/react/src/NodeViewWrapper.tsx
Normal file
27
packages/react/src/NodeViewWrapper.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
114
packages/react/src/ReactMarkViewRenderer.tsx
Normal file
114
packages/react/src/ReactMarkViewRenderer.tsx
Normal 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)
|
||||
}
|
||||
566
packages/react/src/ReactNodeViewRenderer.test.ts
Normal file
566
packages/react/src/ReactNodeViewRenderer.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
462
packages/react/src/ReactNodeViewRenderer.tsx
Normal file
462
packages/react/src/ReactNodeViewRenderer.tsx
Normal 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)
|
||||
}
|
||||
}
|
||||
113
packages/react/src/ReactNodeViewSelectionTracker.ts
Normal file
113
packages/react/src/ReactNodeViewSelectionTracker.ts
Normal 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
|
||||
}
|
||||
304
packages/react/src/ReactRenderer.tsx
Normal file
304
packages/react/src/ReactRenderer.tsx
Normal 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])
|
||||
})
|
||||
}
|
||||
}
|
||||
448
packages/react/src/ReactWidgetRenderer.test.ts
Normal file
448
packages/react/src/ReactWidgetRenderer.test.ts
Normal 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.
|
||||
})
|
||||
97
packages/react/src/ReactWidgetRenderer.tsx
Normal file
97
packages/react/src/ReactWidgetRenderer.tsx
Normal 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
|
||||
},
|
||||
})
|
||||
}
|
||||
232
packages/react/src/Tiptap.tsx
Normal file
232
packages/react/src/Tiptap.tsx
Normal 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
|
||||
76
packages/react/src/captureDOMSelection.test.ts
Normal file
76
packages/react/src/captureDOMSelection.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
35
packages/react/src/captureDOMSelection.ts
Normal file
35
packages/react/src/captureDOMSelection.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/react/src/index.ts
Normal file
16
packages/react/src/index.ts
Normal 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'
|
||||
@@ -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
|
||||
}
|
||||
178
packages/react/src/menus/BubbleMenu.test.ts
Normal file
178
packages/react/src/menus/BubbleMenu.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
160
packages/react/src/menus/BubbleMenu.tsx
Normal file
160
packages/react/src/menus/BubbleMenu.tsx
Normal 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)
|
||||
},
|
||||
)
|
||||
168
packages/react/src/menus/FloatingMenu.test.ts
Normal file
168
packages/react/src/menus/FloatingMenu.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
161
packages/react/src/menus/FloatingMenu.tsx
Normal file
161
packages/react/src/menus/FloatingMenu.tsx
Normal 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)
|
||||
},
|
||||
)
|
||||
5
packages/react/src/menus/getAutoPluginKey.ts
Normal file
5
packages/react/src/menus/getAutoPluginKey.ts
Normal 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)
|
||||
}
|
||||
4
packages/react/src/menus/index.ts
Normal file
4
packages/react/src/menus/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
'use client'
|
||||
|
||||
export * from './BubbleMenu.js'
|
||||
export * from './FloatingMenu.js'
|
||||
369
packages/react/src/menus/useMenuElementProps.ts
Normal file
369
packages/react/src/menus/useMenuElementProps.ts
Normal 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])
|
||||
}
|
||||
8
packages/react/src/types.ts
Normal file
8
packages/react/src/types.ts
Normal 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
|
||||
}
|
||||
379
packages/react/src/useEditor.ts
Normal file
379
packages/react/src/useEditor.ts
Normal 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
|
||||
}
|
||||
121
packages/react/src/useEditorState.test.ts
Normal file
121
packages/react/src/useEditorState.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
187
packages/react/src/useEditorState.ts
Normal file
187
packages/react/src/useEditorState.ts
Normal 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
|
||||
}
|
||||
38
packages/react/src/useReactNodeView.ts
Normal file
38
packages/react/src/useReactNodeView.ts
Normal 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)
|
||||
26
packages/react/tests/integration/useClientBoundary.test.ts
Normal file
26
packages/react/tests/integration/useClientBoundary.test.ts
Normal 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;?$/)
|
||||
})
|
||||
})
|
||||
18
packages/react/vite.config.ts
Normal file
18
packages/react/vite.config.ts
Normal 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,
|
||||
})),
|
||||
})
|
||||
Reference in New Issue
Block a user