UNPKG

@tiptap/react

Version:

React components for tiptap

1 lines 83.5 kB
{"version":3,"sources":["../src/index.ts","../src/Context.tsx","../src/EditorContent.tsx","../src/useEditor.ts","../src/useEditorState.ts","../src/useReactNodeView.ts","../src/NodeViewContent.tsx","../src/NodeViewWrapper.tsx","../src/ReactMarkViewRenderer.tsx","../src/ReactRenderer.tsx","../src/ReactNodeViewRenderer.tsx","../src/Tiptap.tsx"],"sourcesContent":["export * from './Context.js'\nexport * from './EditorContent.js'\nexport * from './NodeViewContent.js'\nexport * from './NodeViewWrapper.js'\nexport * from './ReactMarkViewRenderer.js'\nexport * from './ReactNodeViewRenderer.js'\nexport * from './ReactRenderer.js'\nexport * from './Tiptap.js'\nexport * from './types.js'\nexport * from './useEditor.js'\nexport * from './useEditorState.js'\nexport * from './useReactNodeView.js'\nexport * from '@tiptap/core'\n","import type { Editor } from '@tiptap/core'\nimport type { HTMLAttributes, ReactNode } from 'react'\nimport React, { createContext, useContext, useMemo } from 'react'\n\nimport { EditorContent } from './EditorContent.js'\nimport type { UseEditorOptions } from './useEditor.js'\nimport { useEditor } from './useEditor.js'\n\nexport type EditorContextValue = {\n editor: Editor | null\n}\n\nexport const EditorContext = createContext<EditorContextValue>({\n editor: null,\n})\n\nexport const EditorConsumer = EditorContext.Consumer\n\n/**\n * A hook to get the current editor instance.\n */\nexport const useCurrentEditor = () => useContext(EditorContext)\n\nexport type EditorProviderProps = {\n children?: ReactNode\n slotBefore?: ReactNode\n slotAfter?: ReactNode\n editorContainerProps?: HTMLAttributes<HTMLDivElement>\n} & UseEditorOptions\n\n/**\n * This is the provider component for the editor.\n * It allows the editor to be accessible across the entire component tree\n * with `useCurrentEditor`.\n */\nexport function EditorProvider({\n children,\n slotAfter,\n slotBefore,\n editorContainerProps = {},\n ...editorOptions\n}: EditorProviderProps) {\n const editor = useEditor(editorOptions)\n const contextValue = useMemo(() => ({ editor }), [editor])\n\n if (!editor) {\n return null\n }\n\n return (\n <EditorContext.Provider value={contextValue}>\n {slotBefore}\n <EditorConsumer>\n {({ editor: currentEditor }) => (\n <EditorContent editor={currentEditor} {...editorContainerProps} />\n )}\n </EditorConsumer>\n {children}\n {slotAfter}\n </EditorContext.Provider>\n )\n}\n","import type { Editor } from '@tiptap/core'\nimport type { ForwardedRef, HTMLProps, LegacyRef, MutableRefObject } from 'react'\nimport React, { forwardRef } from 'react'\nimport ReactDOM from 'react-dom'\nimport { useSyncExternalStore } from 'use-sync-external-store/shim/index.js'\n\nimport type { ContentComponent, EditorWithContentComponent } from './Editor.js'\nimport type { ReactRenderer } from './ReactRenderer.js'\n\nconst mergeRefs = <T extends HTMLDivElement>(\n ...refs: Array<MutableRefObject<T> | LegacyRef<T> | undefined>\n) => {\n return (node: T) => {\n refs.forEach(ref => {\n if (typeof ref === 'function') {\n ref(node)\n } else if (ref) {\n ;(ref as MutableRefObject<T | null>).current = node\n }\n })\n }\n}\n\n/**\n * This component renders all of the editor's node views.\n */\nconst Portals: React.FC<{ contentComponent: ContentComponent }> = ({ contentComponent }) => {\n // For performance reasons, we render the node view portals on state changes only\n const renderers = useSyncExternalStore(\n contentComponent.subscribe,\n contentComponent.getSnapshot,\n contentComponent.getServerSnapshot,\n )\n\n // This allows us to directly render the portals without any additional wrapper\n return <>{Object.values(renderers)}</>\n}\n\nexport interface EditorContentProps extends HTMLProps<HTMLDivElement> {\n editor: Editor | null\n innerRef?: ForwardedRef<HTMLDivElement | null>\n}\n\nfunction getInstance(): ContentComponent {\n const subscribers = new Set<() => void>()\n let renderers: Record<string, React.ReactPortal> = {}\n\n return {\n /**\n * Subscribe to the editor instance's changes.\n */\n subscribe(callback: () => void) {\n subscribers.add(callback)\n return () => {\n subscribers.delete(callback)\n }\n },\n getSnapshot() {\n return renderers\n },\n getServerSnapshot() {\n return renderers\n },\n /**\n * Adds a new NodeView Renderer to the editor.\n */\n setRenderer(id: string, renderer: ReactRenderer) {\n renderers = {\n ...renderers,\n [id]: ReactDOM.createPortal(renderer.reactElement, renderer.element, id),\n }\n\n subscribers.forEach(subscriber => subscriber())\n },\n /**\n * Removes a NodeView Renderer from the editor.\n */\n removeRenderer(id: string) {\n const nextRenderers = { ...renderers }\n\n delete nextRenderers[id]\n renderers = nextRenderers\n subscribers.forEach(subscriber => subscriber())\n },\n }\n}\n\nexport class PureEditorContent extends React.Component<\n EditorContentProps,\n { hasContentComponentInitialized: boolean }\n> {\n editorContentRef: React.RefObject<any>\n\n constructor(props: EditorContentProps) {\n super(props)\n this.editorContentRef = React.createRef()\n }\n\n componentDidMount() {\n this.init()\n }\n\n componentDidUpdate() {\n this.init()\n }\n\n init() {\n const editor = this.props.editor as EditorWithContentComponent | null\n\n if (editor && !editor.isDestroyed && editor.view.dom?.parentNode) {\n if (editor.contentComponent) {\n return\n }\n\n const element = this.editorContentRef.current\n\n element.append(...editor.view.dom.parentNode.childNodes)\n\n editor.setOptions({\n element,\n })\n\n editor.contentComponent = getInstance()\n\n editor.createNodeViews()\n\n editor.isEditorContentInitialized = true\n\n this.forceUpdate()\n }\n }\n\n componentWillUnmount() {\n const editor = this.props.editor as EditorWithContentComponent | null\n\n if (!editor) {\n return\n }\n\n editor.isEditorContentInitialized = false\n\n if (!editor.isDestroyed) {\n editor.view.setProps({\n nodeViews: {},\n })\n }\n\n editor.contentComponent = null\n\n // try to reset the editor element\n // may fail if this editor's view.dom was never initialized/mounted yet\n try {\n if (!editor.view.dom?.parentNode) {\n return\n }\n\n // TODO using the new editor.mount method might allow us to remove this\n const newElement = document.createElement('div')\n\n newElement.append(...editor.view.dom.parentNode.childNodes)\n\n editor.setOptions({\n element: newElement,\n })\n } catch {\n // do nothing, nothing to reset\n }\n }\n\n render() {\n const { editor, innerRef, ...rest } = this.props\n\n return (\n <>\n <div ref={mergeRefs(innerRef, this.editorContentRef)} {...rest} />\n {/* @ts-ignore */}\n {editor?.contentComponent && <Portals contentComponent={editor.contentComponent} />}\n </>\n )\n }\n}\n\n// EditorContent should be re-created whenever the Editor instance changes\nconst EditorContentWithKey = forwardRef<HTMLDivElement, EditorContentProps>(\n (props: Omit<EditorContentProps, 'innerRef'>, ref) => {\n const key = React.useMemo(() => {\n return Math.floor(Math.random() * 0xffffffff).toString()\n // oxlint-disable-next-line react-hooks/exhaustive-deps\n }, [props.editor])\n\n // Can't use JSX here because it conflicts with the type definition of Vue's JSX, so use createElement\n return React.createElement(PureEditorContent, {\n key,\n innerRef: ref,\n ...props,\n })\n },\n)\n\nexport const EditorContent = React.memo(EditorContentWithKey)\n","import { type EditorOptions, Editor } from '@tiptap/core'\nimport type { DependencyList, MutableRefObject } from 'react'\nimport { useDebugValue, useEffect, useRef, useState } from 'react'\nimport { useSyncExternalStore } from 'use-sync-external-store/shim/index.js'\n\nimport { useEditorState } from './useEditorState.js'\n\n// @ts-ignore\nconst isDev = process.env.NODE_ENV !== 'production'\nconst isSSR = typeof window === 'undefined'\nconst isNext = isSSR || Boolean(typeof window !== 'undefined' && (window as any).next)\n\n/**\n * The options for the `useEditor` hook.\n */\nexport type UseEditorOptions = Partial<EditorOptions> & {\n /**\n * Whether to render the editor on the first render.\n * If client-side rendering, set this to `true`.\n * If server-side rendering, set this to `false`.\n * @default true\n */\n immediatelyRender?: boolean\n /**\n * Whether to re-render the editor on each transaction.\n * This is legacy behavior that will be removed in future versions.\n * @default false\n */\n shouldRerenderOnTransaction?: boolean\n}\n\n/**\n * This class handles the creation, destruction, and re-creation of the editor instance.\n */\nclass EditorInstanceManager {\n /**\n * The current editor instance.\n */\n private editor: Editor | null = null\n\n /**\n * The most recent options to apply to the editor.\n */\n private options: MutableRefObject<UseEditorOptions>\n\n /**\n * The subscriptions to notify when the editor instance\n * has been created or destroyed.\n */\n private subscriptions = new Set<() => void>()\n\n /**\n * A timeout to destroy the editor if it was not mounted within a time frame.\n */\n private scheduledDestructionTimeout: ReturnType<typeof setTimeout> | undefined\n\n /**\n * Whether the editor has been mounted.\n */\n private isComponentMounted = false\n\n /**\n * The most recent dependencies array.\n */\n private previousDeps: DependencyList | null = null\n\n /**\n * The unique instance ID. This is used to identify the editor instance. And will be re-generated for each new instance.\n */\n public instanceId = ''\n\n constructor(options: MutableRefObject<UseEditorOptions>) {\n this.options = options\n this.subscriptions = new Set<() => void>()\n this.setEditor(this.getInitialEditor())\n this.scheduleDestroy()\n\n this.getEditor = this.getEditor.bind(this)\n this.getServerSnapshot = this.getServerSnapshot.bind(this)\n this.subscribe = this.subscribe.bind(this)\n this.refreshEditorInstance = this.refreshEditorInstance.bind(this)\n this.scheduleDestroy = this.scheduleDestroy.bind(this)\n this.onRender = this.onRender.bind(this)\n this.createEditor = this.createEditor.bind(this)\n }\n\n private setEditor(editor: Editor | null) {\n this.editor = editor\n this.instanceId = Math.random().toString(36).slice(2, 9)\n\n // Notify all subscribers that the editor instance has been created\n this.subscriptions.forEach(cb => cb())\n }\n\n private getInitialEditor() {\n const explicit = this.options.current.immediatelyRender\n let immediatelyRender = explicit ?? true\n\n if (isSSR) {\n if (immediatelyRender && isDev) {\n console.warn(\n 'SSR detected. `immediatelyRender` has been set to false to avoid hydration mismatches',\n )\n }\n immediatelyRender = false\n } else if (isNext && explicit === undefined) {\n immediatelyRender = false\n if (isDev) {\n console.warn(\n '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.',\n )\n }\n }\n\n return immediatelyRender ? this.createEditor() : null\n }\n\n /**\n * Create a new editor instance. And attach event listeners.\n */\n private createEditor(): Editor {\n const optionsToApply: Partial<EditorOptions> = {\n ...this.options.current,\n // Always call the most recent version of the callback function by default\n onBeforeCreate: (...args) => this.options.current.onBeforeCreate?.(...args),\n onBlur: (...args) => this.options.current.onBlur?.(...args),\n onCreate: (...args) => this.options.current.onCreate?.(...args),\n onDestroy: (...args) => this.options.current.onDestroy?.(...args),\n onFocus: (...args) => this.options.current.onFocus?.(...args),\n onSelectionUpdate: (...args) => this.options.current.onSelectionUpdate?.(...args),\n onTransaction: (...args) => this.options.current.onTransaction?.(...args),\n onUpdate: (...args) => this.options.current.onUpdate?.(...args),\n onContentError: (...args) => this.options.current.onContentError?.(...args),\n onDrop: (...args) => this.options.current.onDrop?.(...args),\n onPaste: (...args) => this.options.current.onPaste?.(...args),\n onDelete: (...args) => this.options.current.onDelete?.(...args),\n }\n const editor = new Editor(optionsToApply)\n\n // no need to keep track of the event listeners, they will be removed when the editor is destroyed\n\n return editor\n }\n\n /**\n * Get the current editor instance.\n */\n getEditor(): Editor | null {\n return this.editor\n }\n\n /**\n * Always disable the editor on the server-side.\n */\n getServerSnapshot(): null {\n return null\n }\n\n /**\n * Subscribe to the editor instance's changes.\n */\n subscribe(onStoreChange: () => void) {\n this.subscriptions.add(onStoreChange)\n\n return () => {\n this.subscriptions.delete(onStoreChange)\n }\n }\n\n static compareOptions(a: UseEditorOptions, b: UseEditorOptions) {\n return (Object.keys(a) as (keyof UseEditorOptions)[]).every(key => {\n if (\n [\n 'onCreate',\n 'onBeforeCreate',\n 'onDestroy',\n 'onUpdate',\n 'onTransaction',\n 'onFocus',\n 'onBlur',\n 'onSelectionUpdate',\n 'onContentError',\n 'onDrop',\n 'onPaste',\n ].includes(key)\n ) {\n // we don't want to compare callbacks, they are always different and only registered once\n return true\n }\n\n // We often encourage putting extensions inlined in the options object, so we will do a slightly deeper comparison here\n if (key === 'extensions' && a.extensions && b.extensions) {\n if (a.extensions.length !== b.extensions.length) {\n return false\n }\n return a.extensions.every((extension, index) => {\n if (extension !== b.extensions?.[index]) {\n return false\n }\n return true\n })\n }\n if (a[key] !== b[key]) {\n // if any of the options have changed, we should update the editor options\n return false\n }\n return true\n })\n }\n\n /**\n * On each render, we will create, update, or destroy the editor instance.\n * @param deps The dependencies to watch for changes\n * @returns A cleanup function\n */\n onRender(deps: DependencyList) {\n // The returned callback will run on each render\n return () => {\n this.isComponentMounted = true\n // Cleanup any scheduled destructions, since we are currently rendering\n clearTimeout(this.scheduledDestructionTimeout)\n\n if (this.editor && !this.editor.isDestroyed && deps.length === 0) {\n // if the editor does exist & deps are empty, we don't need to re-initialize the editor generally\n if (!EditorInstanceManager.compareOptions(this.options.current, this.editor.options)) {\n // But, the options are different, so we need to update the editor options\n // Still, this is faster than re-creating the editor\n this.editor.setOptions({\n ...this.options.current,\n editable: this.editor.isEditable,\n })\n }\n } else {\n // When the editor:\n // - does not yet exist\n // - is destroyed\n // - the deps array changes\n // We need to destroy the editor instance and re-initialize it\n this.refreshEditorInstance(deps)\n }\n\n return () => {\n this.isComponentMounted = false\n this.scheduleDestroy()\n }\n }\n }\n\n /**\n * Recreate the editor instance if the dependencies have changed.\n */\n private refreshEditorInstance(deps: DependencyList) {\n if (this.editor && !this.editor.isDestroyed) {\n // Editor instance already exists\n if (this.previousDeps === null) {\n // If lastDeps has not yet been initialized, reuse the current editor instance\n this.previousDeps = deps\n return\n }\n const depsAreEqual =\n this.previousDeps.length === deps.length &&\n this.previousDeps.every((dep, index) => dep === deps[index])\n\n if (depsAreEqual) {\n // deps exist and are equal, no need to recreate\n return\n }\n }\n\n if (this.editor && !this.editor.isDestroyed) {\n // Destroy the editor instance if it exists\n this.editor.destroy()\n }\n\n this.setEditor(this.createEditor())\n\n // Update the lastDeps to the current deps\n this.previousDeps = deps\n }\n\n /**\n * Schedule the destruction of the editor instance.\n * This will only destroy the editor if it was not mounted on the next tick.\n * This is to avoid destroying the editor instance when it's actually still mounted.\n */\n private scheduleDestroy() {\n const currentInstanceId = this.instanceId\n const currentEditor = this.editor\n\n // Wait two ticks to see if the component is still mounted\n this.scheduledDestructionTimeout = setTimeout(() => {\n if (this.isComponentMounted && this.instanceId === currentInstanceId) {\n // If still mounted on the following tick, with the same instanceId, do not destroy the editor\n if (currentEditor) {\n // just re-apply options as they might have changed\n currentEditor.setOptions(this.options.current)\n }\n return\n }\n if (currentEditor && !currentEditor.isDestroyed) {\n currentEditor.destroy()\n if (this.instanceId === currentInstanceId) {\n this.setEditor(null)\n }\n }\n // This allows the effect to run again between ticks\n // which may save us from having to re-create the editor\n }, 1)\n }\n}\n\n/**\n * This hook allows you to create an editor instance.\n * @param options The editor options\n * @param deps The dependencies to watch for changes\n * @returns The editor instance\n * @example const editor = useEditor({ extensions: [...] })\n */\nexport function useEditor(\n options: UseEditorOptions & { immediatelyRender: false },\n deps?: DependencyList,\n): Editor | null\n\n/**\n * This hook allows you to create an editor instance.\n * @param options The editor options\n * @param deps The dependencies to watch for changes\n * @returns The editor instance\n * @example const editor = useEditor({ extensions: [...] })\n */\nexport function useEditor(options: UseEditorOptions, deps?: DependencyList): Editor\n\nexport function useEditor(\n options: UseEditorOptions = {},\n deps: DependencyList = [],\n): Editor | null {\n const mostRecentOptions = useRef(options)\n\n mostRecentOptions.current = options\n\n const [instanceManager] = useState(() => new EditorInstanceManager(mostRecentOptions))\n\n const editor = useSyncExternalStore(\n instanceManager.subscribe,\n instanceManager.getEditor,\n instanceManager.getServerSnapshot,\n )\n\n useDebugValue(editor)\n\n // This effect will handle creating/updating the editor instance\n // oxlint-disable-next-line react-hooks/exhaustive-deps\n useEffect(instanceManager.onRender(deps))\n\n // The default behavior is to re-render on each transaction\n // This is legacy behavior that will be removed in future versions\n useEditorState({\n editor,\n selector: ({ transactionNumber }) => {\n if (\n options.shouldRerenderOnTransaction === false ||\n options.shouldRerenderOnTransaction === undefined\n ) {\n // This will prevent the editor from re-rendering on each transaction\n return null\n }\n\n // This will avoid re-rendering on the first transaction when `immediatelyRender` is set to `true`\n if (options.immediatelyRender && transactionNumber === 0) {\n return 0\n }\n return transactionNumber + 1\n },\n })\n\n return editor\n}\n","import type { Editor } from '@tiptap/core'\nimport { deepEqual } from 'fast-equals'\nimport { useDebugValue, useEffect, useLayoutEffect, useState } from 'react'\nimport { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector.js'\n\nconst useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect\n\nexport type EditorStateSnapshot<TEditor extends Editor | null = Editor | null> = {\n editor: TEditor\n transactionNumber: number\n}\n\nexport type UseEditorStateOptions<\n TSelectorResult,\n TEditor extends Editor | null = Editor | null,\n> = {\n /**\n * The editor instance.\n */\n editor: TEditor\n /**\n * A selector function to determine the value to compare for re-rendering.\n */\n selector: (context: EditorStateSnapshot<TEditor>) => TSelectorResult\n /**\n * A custom equality function to determine if the editor should re-render.\n * @default `deepEqual` from `fast-deep-equal`\n */\n equalityFn?: (a: TSelectorResult, b: TSelectorResult | null) => boolean\n}\n\n/**\n * To synchronize the editor instance with the component state,\n * we need to create a separate instance that is not affected by the component re-renders.\n */\nclass EditorStateManager<TEditor extends Editor | null = Editor | null> {\n private transactionNumber = 0\n\n private lastTransactionNumber = 0\n\n private lastSnapshot: EditorStateSnapshot<TEditor>\n\n private editor: TEditor\n\n private subscribers = new Set<() => void>()\n\n constructor(initialEditor: TEditor) {\n this.editor = initialEditor\n this.lastSnapshot = { editor: initialEditor, transactionNumber: 0 }\n\n this.getSnapshot = this.getSnapshot.bind(this)\n this.getServerSnapshot = this.getServerSnapshot.bind(this)\n this.watch = this.watch.bind(this)\n this.subscribe = this.subscribe.bind(this)\n }\n\n /**\n * Get the current editor instance.\n */\n getSnapshot(): EditorStateSnapshot<TEditor> {\n if (this.transactionNumber === this.lastTransactionNumber) {\n return this.lastSnapshot\n }\n this.lastTransactionNumber = this.transactionNumber\n this.lastSnapshot = { editor: this.editor, transactionNumber: this.transactionNumber }\n return this.lastSnapshot\n }\n\n /**\n * Always disable the editor on the server-side.\n */\n getServerSnapshot(): EditorStateSnapshot<null> {\n return { editor: null, transactionNumber: 0 }\n }\n\n /**\n * Subscribe to the editor instance's changes.\n */\n subscribe(callback: () => void): () => void {\n this.subscribers.add(callback)\n return () => {\n this.subscribers.delete(callback)\n }\n }\n\n /**\n * Watch the editor instance for changes.\n */\n watch(nextEditor: Editor | null): undefined | (() => void) {\n this.editor = nextEditor as TEditor\n\n if (this.editor) {\n /**\n * This will force a re-render when the editor state changes.\n * This is to support things like `editor.can().toggleBold()` in components that `useEditor`.\n * This could be more efficient, but it's a good trade-off for now.\n */\n const fn = () => {\n this.transactionNumber += 1\n this.subscribers.forEach(callback => callback())\n }\n\n const currentEditor = this.editor\n\n currentEditor.on('transaction', fn)\n return () => {\n currentEditor.off('transaction', fn)\n }\n }\n\n return undefined\n }\n}\n\n/**\n * This hook allows you to watch for changes on the editor instance.\n * It will allow you to select a part of the editor state and re-render the component when it changes.\n * @example\n * ```tsx\n * const editor = useEditor({...options})\n * const { currentSelection } = useEditorState({\n * editor,\n * selector: snapshot => ({ currentSelection: snapshot.editor.state.selection }),\n * })\n */\nexport function useEditorState<TSelectorResult>(\n options: UseEditorStateOptions<TSelectorResult, Editor>,\n): TSelectorResult\n/**\n * This hook allows you to watch for changes on the editor instance.\n * It will allow you to select a part of the editor state and re-render the component when it changes.\n * @example\n * ```tsx\n * const editor = useEditor({...options})\n * const { currentSelection } = useEditorState({\n * editor,\n * selector: snapshot => ({ currentSelection: snapshot.editor.state.selection }),\n * })\n */\nexport function useEditorState<TSelectorResult>(\n options: UseEditorStateOptions<TSelectorResult, Editor | null>,\n): TSelectorResult | null\n\n/**\n * This hook allows you to watch for changes on the editor instance.\n * It will allow you to select a part of the editor state and re-render the component when it changes.\n * @example\n * ```tsx\n * const editor = useEditor({...options})\n * const { currentSelection } = useEditorState({\n * editor,\n * selector: snapshot => ({ currentSelection: snapshot.editor.state.selection }),\n * })\n */\nexport function useEditorState<TSelectorResult>(\n options:\n | UseEditorStateOptions<TSelectorResult, Editor>\n | UseEditorStateOptions<TSelectorResult, Editor | null>,\n): TSelectorResult | null {\n const [editorStateManager] = useState(() => new EditorStateManager(options.editor))\n\n // Using the `useSyncExternalStore` hook to sync the editor instance with the component state\n const selectedState = useSyncExternalStoreWithSelector(\n editorStateManager.subscribe,\n editorStateManager.getSnapshot,\n editorStateManager.getServerSnapshot,\n options.selector as UseEditorStateOptions<TSelectorResult, Editor | null>['selector'],\n options.equalityFn ?? deepEqual,\n )\n\n useIsomorphicLayoutEffect(() => {\n return editorStateManager.watch(options.editor)\n }, [options.editor, editorStateManager])\n\n useDebugValue(selectedState)\n\n return selectedState\n}\n","import type { ReactNode } from 'react'\nimport { createContext, createElement, useContext } from 'react'\n\nexport interface ReactNodeViewContextProps {\n onDragStart?: (event: DragEvent) => void\n nodeViewContentRef?: (element: HTMLElement | null) => void\n /**\n * This allows you to add children into the NodeViewContent component.\n * This is useful when statically rendering the content of a node view.\n */\n nodeViewContentChildren?: ReactNode\n}\n\nexport const ReactNodeViewContext = createContext<ReactNodeViewContextProps>({\n onDragStart: () => {\n // no-op\n },\n nodeViewContentChildren: undefined,\n nodeViewContentRef: () => {\n // no-op\n },\n})\n\nexport const ReactNodeViewContentProvider = ({\n children,\n content,\n}: {\n children: ReactNode\n content: ReactNode\n}) => {\n return createElement(\n ReactNodeViewContext.Provider,\n { value: { nodeViewContentChildren: content } },\n children,\n )\n}\n\nexport const useReactNodeView = () => useContext(ReactNodeViewContext)\n","import type { ComponentProps } from 'react'\nimport React from 'react'\n\nimport { useReactNodeView } from './useReactNodeView.js'\n\nexport type NodeViewContentProps<T extends keyof React.JSX.IntrinsicElements = 'div'> = {\n as?: NoInfer<T>\n} & ComponentProps<T>\n\nexport function NodeViewContent<T extends keyof React.JSX.IntrinsicElements = 'div'>({\n as: Tag = 'div' as T,\n ...props\n}: NodeViewContentProps<T>) {\n const { nodeViewContentRef, nodeViewContentChildren } = useReactNodeView()\n\n return (\n // @ts-ignore\n <Tag\n {...props}\n ref={nodeViewContentRef}\n data-node-view-content=\"\"\n style={{\n whiteSpace: 'pre-wrap',\n ...props.style,\n }}\n >\n {nodeViewContentChildren}\n </Tag>\n )\n}\n","import React from 'react'\n\nimport { useReactNodeView } from './useReactNodeView.js'\n\nexport interface NodeViewWrapperProps {\n [key: string]: any\n as?: React.ElementType\n}\n\nexport const NodeViewWrapper: React.FC<NodeViewWrapperProps> = React.forwardRef((props, ref) => {\n const { onDragStart } = useReactNodeView()\n const Tag = props.as || 'div'\n\n return (\n // @ts-ignore\n <Tag\n {...props}\n ref={ref}\n data-node-view-wrapper=\"\"\n onDragStart={onDragStart}\n style={{\n whiteSpace: 'normal',\n ...props.style,\n }}\n />\n )\n})\n","/* oslint-disableno-shadow */\nimport type { MarkViewProps, MarkViewRenderer, MarkViewRendererOptions } from '@tiptap/core'\nimport { MarkView } from '@tiptap/core'\nimport React from 'react'\n\n// import { flushSync } from 'react-dom'\nimport { ReactRenderer } from './ReactRenderer.js'\n\nexport interface MarkViewContextProps {\n markViewContentRef: (element: HTMLElement | null) => void\n}\nexport const ReactMarkViewContext = React.createContext<MarkViewContextProps>({\n markViewContentRef: () => {\n // do nothing\n },\n})\n\nexport type MarkViewContentProps<T extends keyof React.JSX.IntrinsicElements = 'span'> = {\n as?: T\n} & Omit<React.ComponentProps<T>, 'as'>\n\nexport const MarkViewContent = <T extends keyof React.JSX.IntrinsicElements = 'span'>(\n props: MarkViewContentProps<T>,\n) => {\n const { as: Tag = 'span', ...rest } = props\n const { markViewContentRef } = React.useContext(ReactMarkViewContext)\n\n return (\n // @ts-ignore\n <Tag {...rest} ref={markViewContentRef} data-mark-view-content=\"\" />\n )\n}\n\nexport interface ReactMarkViewRendererOptions extends MarkViewRendererOptions {\n /**\n * The tag name of the element wrapping the React component.\n */\n as?: string\n className?: string\n attrs?: { [key: string]: string }\n}\n\nexport class ReactMarkView extends MarkView<\n React.ComponentType<MarkViewProps>,\n ReactMarkViewRendererOptions\n> {\n renderer: ReactRenderer\n contentDOMElement: HTMLElement\n\n constructor(\n component: React.ComponentType<MarkViewProps>,\n props: MarkViewProps,\n options?: Partial<ReactMarkViewRendererOptions>,\n ) {\n super(component, props, options)\n\n const { as = 'span', attrs, className = '' } = options || {}\n const componentProps = {\n ...props,\n updateAttributes: this.updateAttributes.bind(this),\n } satisfies MarkViewProps\n\n this.contentDOMElement = document.createElement('span')\n\n const markViewContentRef: MarkViewContextProps['markViewContentRef'] = el => {\n if (el && !el.contains(this.contentDOMElement)) {\n el.appendChild(this.contentDOMElement)\n }\n }\n const context: MarkViewContextProps = {\n markViewContentRef,\n }\n\n // For performance reasons, we memoize the provider component\n // And all of the things it requires are declared outside of the component, so it doesn't need to re-render\n const ReactMarkViewProvider: React.FunctionComponent<MarkViewProps> = React.memo(\n componentProps => {\n return (\n <ReactMarkViewContext.Provider value={context}>\n {React.createElement(component, componentProps)}\n </ReactMarkViewContext.Provider>\n )\n },\n )\n\n ReactMarkViewProvider.displayName = 'ReactMarkView'\n\n this.renderer = new ReactRenderer(ReactMarkViewProvider, {\n editor: props.editor,\n props: componentProps,\n as,\n className: `mark-${props.mark.type.name} ${className}`.trim(),\n })\n\n if (attrs) {\n this.renderer.updateAttributes(attrs)\n }\n }\n\n get dom() {\n return this.renderer.element\n }\n\n get contentDOM() {\n return this.contentDOMElement\n }\n}\n\nexport function ReactMarkViewRenderer(\n component: React.ComponentType<MarkViewProps>,\n options: Partial<ReactMarkViewRendererOptions> = {},\n): MarkViewRenderer {\n return props => new ReactMarkView(component, props, options)\n}\n","import type { Editor } from '@tiptap/core'\nimport type {\n ComponentClass,\n ForwardRefExoticComponent,\n FunctionComponent,\n PropsWithoutRef,\n ReactNode,\n RefAttributes,\n} from 'react'\nimport { version as reactVersion } from 'react'\nimport { flushSync } from 'react-dom'\n\nimport type { EditorWithContentComponent } from './Editor.js'\n\n/**\n * Check if a component is a class component.\n * @param Component\n * @returns {boolean}\n */\nfunction isClassComponent(Component: any) {\n return !!(\n typeof Component === 'function' &&\n Component.prototype &&\n Component.prototype.isReactComponent\n )\n}\n\n/**\n * Check if a component is a forward ref component.\n * @param Component\n * @returns {boolean}\n */\nfunction isForwardRefComponent(Component: any) {\n return !!(\n typeof Component === 'object' &&\n Component.$$typeof &&\n (Component.$$typeof.toString() === 'Symbol(react.forward_ref)' ||\n Component.$$typeof.description === 'react.forward_ref')\n )\n}\n\n/**\n * Check if a component is a memoized component.\n * @param Component\n * @returns {boolean}\n */\nfunction isMemoComponent(Component: any) {\n return !!(\n typeof Component === 'object' &&\n Component.$$typeof &&\n (Component.$$typeof.toString() === 'Symbol(react.memo)' ||\n Component.$$typeof.description === 'react.memo')\n )\n}\n\n/**\n * Check if a component can safely receive a ref prop.\n * This includes class components, forwardRef components, and memoized components\n * that wrap forwardRef or class components.\n * @param Component\n * @returns {boolean}\n */\nfunction canReceiveRef(Component: any) {\n // Check if it's a class component\n if (isClassComponent(Component)) {\n return true\n }\n\n // Check if it's a forwardRef component\n if (isForwardRefComponent(Component)) {\n return true\n }\n\n // Check if it's a memoized component\n if (isMemoComponent(Component)) {\n // For memoized components, check the wrapped component\n const wrappedComponent = Component.type\n if (wrappedComponent) {\n return isClassComponent(wrappedComponent) || isForwardRefComponent(wrappedComponent)\n }\n }\n\n return false\n}\n\n/**\n * Check if we're running React 19+ by detecting if function components support ref props\n * @returns {boolean}\n */\nfunction isReact19Plus(): boolean {\n // React 19 is detected by checking React version if available\n // In practice, we'll use a more conservative approach and assume React 18 behavior\n // unless we can definitively detect React 19\n try {\n // @ts-ignore\n if (reactVersion) {\n const majorVersion = parseInt(reactVersion.split('.')[0], 10)\n return majorVersion >= 19\n }\n } catch {\n // Fallback to React 18 behavior if we can't determine version\n }\n return false\n}\n\nexport interface ReactRendererOptions {\n /**\n * The editor instance.\n * @type {Editor}\n */\n editor: Editor\n\n /**\n * The props for the component.\n * @type {Record<string, any>}\n * @default {}\n */\n props?: Record<string, any>\n\n /**\n * The tag name of the element.\n * @type {string}\n * @default 'div'\n */\n as?: string\n\n /**\n * The class name of the element.\n * @type {string}\n * @default ''\n * @example 'foo bar'\n */\n className?: string\n}\n\ntype ComponentType<R, P> =\n | ComponentClass<P>\n | FunctionComponent<P>\n | ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<R>>\n\n/**\n * The ReactRenderer class. It's responsible for rendering React components inside the editor.\n * @example\n * new ReactRenderer(MyComponent, {\n * editor,\n * props: {\n * foo: 'bar',\n * },\n * as: 'span',\n * })\n */\nexport class ReactRenderer<R = unknown, P extends Record<string, any> = object> {\n id: string\n\n editor: EditorWithContentComponent\n\n component: any\n\n element: HTMLElement\n\n props: P\n\n reactElement: ReactNode\n\n ref: R | null = null\n\n /**\n * Flag to track if the renderer has been destroyed, preventing queued or asynchronous renders from executing after teardown.\n */\n destroyed = false\n\n /**\n * Immediately creates element and renders the provided React component.\n */\n constructor(\n component: ComponentType<R, P>,\n { editor, props = {}, as = 'div', className = '' }: ReactRendererOptions,\n ) {\n this.id = Math.floor(Math.random() * 0xffffffff).toString()\n this.component = component\n this.editor = editor\n this.props = props as P\n this.element = document.createElement(as)\n this.element.classList.add('react-renderer')\n\n if (className) {\n this.element.classList.add(...className.split(' '))\n }\n\n if (this.editor.isEditorContentInitialized) {\n // If EditorContent is mounted, flush synchronously to maintain cursor positioning consistency.\n // Subsequent renders can be async without affecting cursor behavior.\n flushSync(() => {\n this.render()\n })\n } else {\n queueMicrotask(() => {\n if (this.destroyed) {\n return\n }\n this.render()\n })\n }\n }\n\n /**\n * Render the React component.\n */\n render(): void {\n if (this.destroyed) {\n return\n }\n\n const Component = this.component\n const props = this.props\n const editor = this.editor as EditorWithContentComponent\n\n // Handle ref forwarding with React 18/19 compatibility\n const isReact19 = isReact19Plus()\n const componentCanReceiveRef = canReceiveRef(Component)\n\n const elementProps = { ...props }\n\n // Always remove ref if the component cannot receive it (unless React 19+)\n if (elementProps.ref && !(isReact19 || componentCanReceiveRef)) {\n delete elementProps.ref\n }\n\n // Only assign our own ref if allowed\n if (!elementProps.ref && (isReact19 || componentCanReceiveRef)) {\n // @ts-ignore - Setting ref prop for compatible components\n elementProps.ref = (ref: R) => {\n this.ref = ref\n }\n }\n\n this.reactElement = <Component {...elementProps} />\n\n editor?.contentComponent?.setRenderer(this.id, this)\n }\n\n /**\n * Re-renders the React component with new props.\n * Skips the render if none of the supplied props actually changed,\n * to avoid unnecessary portal re-creation.\n */\n updateProps(props: Record<string, any> = {}): void {\n if (this.destroyed) {\n return\n }\n\n // Shallow comparison — skip if every supplied key already holds the same value.\n let changed = false\n const keys = Object.keys(props)\n\n for (let i = 0; i < keys.length; i += 1) {\n const key = keys[i]\n if (props[key] !== this.props[key]) {\n changed = true\n break\n }\n }\n\n if (!changed) {\n return\n }\n\n this.props = {\n ...this.props,\n ...props,\n }\n\n this.render()\n }\n\n /**\n * Destroy the React component.\n */\n destroy(): void {\n this.destroyed = true\n const editor = this.editor as EditorWithContentComponent\n\n editor?.contentComponent?.removeRenderer(this.id)\n // If the consumer appended the element to the document (for example\n // many demos append the renderer element to document.body), make sure\n // we remove it here to avoid leaking DOM nodes / React roots.\n try {\n if (this.element && this.element.parentNode) {\n this.element.parentNode.removeChild(this.element)\n }\n } catch {\n // ignore DOM removal errors\n }\n }\n\n /**\n * Update the attributes of the element that holds the React component.\n */\n updateAttributes(attributes: Record<string, string>): void {\n Object.keys(attributes).forEach(key => {\n this.element.setAttribute(key, attributes[key])\n })\n }\n}\n","import type {\n DecorationWithType,\n Editor,\n NodeViewRenderer,\n NodeViewRendererOptions,\n NodeViewRendererProps,\n} from '@tiptap/core'\nimport { getRenderedAttributes, isNodeViewSelected, NodeView } from '@tiptap/core'\nimport type { Node, Node as ProseMirrorNode } from '@tiptap/pm/model'\nimport type { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'\nimport type { ComponentType, NamedExoticComponent } from 'react'\nimport { createElement, createRef, memo } from 'react'\n\nimport type { EditorWithContentComponent } from './Editor.js'\nimport { ReactRenderer } from './ReactRenderer.js'\nimport type { ReactNodeViewProps } from './types.js'\nimport type { ReactNodeViewContextProps } from './useReactNodeView.js'\nimport { ReactNodeViewContext } from './useReactNodeView.js'\n\nexport interface ReactNodeViewRendererOptions extends NodeViewRendererOptions {\n /**\n * This function is called when the node view is updated.\n * It allows you to compare the old node with the new node and decide if the component should update.\n */\n update:\n | ((props: {\n oldNode: ProseMirrorNode\n oldDecorations: readonly Decoration[]\n oldInnerDecorations: DecorationSource\n newNode: ProseMirrorNode\n newDecorations: readonly Decoration[]\n innerDecorations: DecorationSource\n updateProps: () => void\n }) => boolean)\n | null\n /**\n * The tag name of the element wrapping the React component.\n */\n as?: string\n /**\n * The class name of the element wrapping the React component.\n */\n className?: string\n /**\n * Attributes that should be applied to the element wrapping the React component.\n * If this is a function, it will be called each time the node view is updated.\n * If this is an object, it will be applied once when the node view is mounted.\n */\n attrs?:\n | Record<string, string>\n | ((props: {\n node: ProseMirrorNode\n HTMLAttributes: Record<string, any>\n }) => Record<string, string>)\n}\n\nexport class ReactNodeView<\n T = HTMLElement,\n Component extends ComponentType<ReactNodeViewProps<T>> = ComponentType<ReactNodeViewProps<T>>,\n NodeEditor extends Editor = Editor,\n Options extends ReactNodeViewRendererOptions = ReactNodeViewRendererOptions,\n> extends NodeView<Component, NodeEditor, Options> {\n /**\n * The renderer instance.\n */\n renderer!: ReactRenderer<unknown, ReactNodeViewProps<T>>\n\n /**\n * The element that holds the rich-text content of the node.\n */\n contentDOMElement!: HTMLElement | null\n\n /**\n * The requestAnimationFrame ID used for selection updates.\n */\n selectionRafId: number | null = null\n\n /**\n * The last known position of this node view, used to detect position-only\n * changes that don't produce a new node object reference.\n */\n private currentPos: number | undefined\n\n /**\n * Fires on editor updates when trackNodeViewPosition is enabled.\n * Detects position shifts where update() is NOT called (e.g. typing above).\n */\n private handlePositionUpdate = () => {\n const newPos = this.getPos()\n if (typeof newPos !== 'number' || newPos === this.currentPos) {\n return\n }\n this.currentPos = newPos\n this.renderer.updateProps({ getPos: () => this.getPos() })\n\n if (typeof this.options.attrs === 'function') {\n this.updateElementAttributes()\n }\n }\n\n constructor(component: Component, props: NodeViewRendererProps, options?: Partial<Options>) {\n super(component, props, options)\n\n if (!this.node.isLeaf) {\n if (this.options.contentDOMElementTag) {\n this.contentDOMElement = document.createElement(this.options.contentDOMElementTag)\n } else {\n this.contentDOMElement = document.createElement(this.node.isInline ? 'span' : 'div')\n }\n\n this.contentDOMElement.dataset.nodeViewContentReact = ''\n this.contentDOMElement.dataset.nodeViewWrapper = ''\n\n // For some reason the whiteSpace prop is not inherited properly in Chrome and Safari\n // With this fix it seems to work fine\n // See: https://github.com/ueberdosis/tiptap/issues/1197\n this.contentDOMElement.style.whiteSpace = 'inherit'\n\n const contentTarget = this.dom.querySelector('[data-node-view-content]')\n\n if (!contentTarget) {\n return\n }\n\n contentTarget.appendChild(this.contentDOMElement)\n }\n\n if (this.options.trackNodeViewPosition) {\n this.editor.on('update', this.handlePositionUpdate)\n }\n }\n\n private cachedExtensionWithSyncedStorage: NodeViewRendererProps['extension'] | null = null\n\n /**\n * Returns a proxy of the extension that redirects storage access to the editor's mutable storage.\n * This preserves the original prototype chain (instanceof checks, methods like configure/extend work).\n * Cached to avoid proxy creation on every update.\n */\n get extensionWithSyncedStorage(): NodeViewRendererProps['extension'] {\n if (!this.cachedExtensionWithSyncedStorage) {\n const editor = this.editor\n const extension = this.extension\n\n this.cachedExtensionWithSyncedStorage = new Proxy(extension, {\n get(target, prop, receiver) {\n if (prop === 'storage') {\n return editor.storage[extension.name as keyof typeof editor.storage] ?? {}\n }\n return Reflect.get(target, prop, receiver)\n },\n })\n }\n\n return this.cachedExtensionWithSyncedStorage\n }\n\n /**\n * Setup the React component.\n * Called on initialization.\n */\n mount() {\n const props: Record<string, any> = {\n editor: this.editor,\n node: this.node,\n decorations: this.decorations as DecorationWithType[],\n innerDecorations: this.innerDecorations,\n view: this.view,\n selected: false,\n extension: this.extensionWithSyncedStorage,\n HTMLAttributes: this.HTMLAttributes,\n getPos: () => this.getPos(),\n updateAttributes: (attributes = {}) => this.updateAttributes(attributes),\n deleteNode: () => this.deleteNode(),\n ref: createRef<T>(),\n }\n\n const mountProps = props as ReactNodeViewProps<T>\n\n if (!(this.component as any).displayName) {\n const capitalizeFirstChar = (string: string): string => {\n return string.charAt(0).toUpperCase() + string.substring(1)\n }\n\n this.component.displayName = capitalizeFirstChar(this.extension.name)\n }\n\n const onDragStart = this.onDragStart.bind(this)\n const nodeViewContentRef: ReactNodeViewContextProps['nodeViewContentRef'] = element => {\n if (element && this.contentDOMElement && element.firstChild !== this.contentDOMElement) {\n // remove the nodeViewWrapper attribute from the element\n if (element.hasAttribute('data-node-view-wrapper')) {\n element.removeAttribute('data-node-view-wrapper')\n }\n element.appendChild(this.contentDOMElement)\n }\n }\n const context = { onDragStart, nodeViewContentRef }\n const Component = this.component\n // For performance reasons, we memoize the provider component\n // And all of the things it requires are declared outside of the component, so it doesn't need to re-render\n const ReactNodeViewProvider: NamedExoticComponent<ReactNodeViewProps<T>> = memo(\n componentProps => {\n return (\n <ReactNodeViewContext.Provider value={context}>\n {createElement(Component, componentProps)}\n </ReactNodeViewContext.Provider>\n )\n },\n )\n\n ReactNodeViewProvider.displayName = 'ReactNodeView'\n\n let as = this.node.isInline ? 'span' : 'div'\n\n if (this.options.as) {\n as = this.options.as\n }\n\n const { className = '' } = this.options\n\n this.handleSelectionUpdate = this.handleSelectionUpdate.bind(this)\n\n this.renderer = new ReactRenderer(ReactNodeViewProvider, {\n editor: this.editor,\n props: mountProps,\n as,\n className: `node-${this.node.type.name} ${className}`.trim(),\n })\n\n this.editor.on('selectionUpdate', this.handleSelectionUpdate)\n this.updateElementAttributes()\n this.currentPos = this.getPos()\n }\n\n /**\n * Return the DOM element.\n * This is the element that will be used to display the node view.\n */\n get dom() {\n if (\n this.renderer.element.firstElementChild &&\n !this.renderer.element.firstElementChild?.hasAttribute('data-node-view-wrapper')\n ) {\n throw Error('Please use the NodeViewWrapper component for your node view.')\n }\n\n return this.renderer.element\n }\n\n /**\n * Return the content DOM element.\n * This is the element that will be used to display the rich-text content of the node.\n */\n get contentDOM() {\n if (this.node.isLeaf) {\n return null\n }\n\n return this.contentDOMElement\n }\n\n /**\n * On editor selection update, check if the node is selected.\n * If it is, call `selectNode`, otherwise call `deselectNode`.\n */\n handleSelectionUpdate() {\n if (this.selectionRafId) {\n cancelAnimationFrame(this.selectionRafId)\n this.selectionRafId = null\n }\n\n this.selectionRafId = requestAnimationFrame(() => {\n this.selectionRafId = null\n // Avoid resolving getPos() after ProseMirror has detached this node view.\n const pos = this.currentPos\n if (typeof pos !== 'number') {\n return\n }\n\n const isSelected = isNodeViewSelected({\n selection: this.editor.state.selection,\n pos,\n nodeSize: this.node.nodeSize,\n selectedOnTextSelection: this.options.selectedOnTextSelection,\n })\n\n if (isSelected) {\n if (this.renderer.props.selected) {\n return\n }\n\n