Merge branch 'develop' into next

This commit is contained in:
Nick the Sick 2024-08-14 18:00:55 +02:00
commit 06ffa88cf6
No known key found for this signature in database
GPG Key ID: F575992F156E5BCC
37 changed files with 4384 additions and 2289 deletions

View File

@ -0,0 +1,5 @@
---
"@tiptap/core": patch
---
Resolve several selection related bug #2690 #5208

View File

@ -114,7 +114,7 @@ jobs:
quiet: true
- name: Export screenshots (on failure only)
uses: actions/upload-artifact@v4.3.5
uses: actions/upload-artifact@v4.3.6
if: failure()
with:
name: cypress-screenshots
@ -122,7 +122,7 @@ jobs:
retention-days: 7
- name: Export screen recordings (on failure only)
uses: actions/upload-artifact@v4.3.5
uses: actions/upload-artifact@v4.3.6
if: failure()
with:
name: cypress-videos

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023, Tiptap GmbH
Copyright (c) 2024, Tiptap GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -93,7 +93,7 @@ For help, discussion about best practices, or any other conversation that would
</table>
[iFixit](https://www.ifixit.com/), [ApostropheCMS](https://apostrophecms.com/), [Novadiscovery](http://www.novadiscovery.com/), [Omics Data Automation](https://www.omicsautomation.com), [Flow Mobile](https://www.flowmobile.app/), [DocIQ](https://www.dociq.io/) and [hundreds of awesome inviduals](https://github.com/sponsors/ueberdosis).
[iFixit](https://www.ifixit.com/), [ApostropheCMS](https://apostrophecms.com/), [Novadiscovery](http://www.novadiscovery.com/), [Omics Data Automation](https://www.omicsautomation.com), [Flow Mobile](https://www.flowmobile.app/), [DocIQ](https://www.dociq.io/) and [hundreds of awesome individuals](https://github.com/sponsors/ueberdosis).
### Contributing
Feel like adding some magic of your own to Tiptap Editor Core? We welcome contributions! Please see our [CONTRIBUTING](CONTRIBUTING.md) guidelines for how to get started.

View File

@ -20,6 +20,7 @@ prosemirror-view
react
react-dom
react-dom/client
use-sync-external-store/shim
use-sync-external-store/shim/with-selector
shiki
simplify-js
@ -27,3 +28,5 @@ simplify-js
uuid
y-webrtc
yjs
@hocuspocus/provider
@lifeomic/attempt

View File

@ -10,8 +10,9 @@
"ts": "tsc --project tsconfig.base.json --noEmit && tsc --project tsconfig.react.json --noEmit && tsc --project tsconfig.vue-2.json --noEmit && tsc --project tsconfig.vue-3.json --noEmit"
},
"dependencies": {
"@hocuspocus/provider": "^2.13.5",
"@hocuspocus/provider": "2.13.5",
"@lexical/react": "^0.11.1",
"@shikijs/core": "1.10.3",
"d3": "^7.3.0",
"fast-glob": "^3.2.11",
"highlight.js": "^11.10.0",
@ -20,16 +21,17 @@
"remixicon": "^2.5.0",
"shiki": "^1.10.3",
"simplify-js": "^1.2.4",
"y-prosemirror": "^1.2.11",
"y-prosemirror": "1.2.11",
"y-webrtc": "^10.3.0",
"yjs": "^13.6.18"
"yjs": "13.6.18"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/uuid": "^8.3.4",
"@vitejs/plugin-react": "^1.3.2",
"@vitejs/plugin-vue": "^5.0.0",
"autoprefixer": "^10.4.2",
"esbuild": "0.21.5",
"iframe-resizer": "^4.3.2",
"postcss": "^8.4.31",
"postcss-import": "^15.1.0",
@ -37,7 +39,7 @@
"react": "^18.0.0",
"react-dom": "^18.0.0",
"sass": "^1.49.7",
"svelte": "^3.49.0",
"svelte": "^4.0.0",
"tailwindcss": "^3.3.2",
"typescript": "^5.4.5",
"uuid": "^8.3.2",

View File

@ -3,5 +3,22 @@ context('/src/Demos/CollaborationSplitPane/React/', () => {
cy.visit('/src/Demos/CollaborationSplitPane/React/')
})
// TODO: Write tests
it('should have a working tiptap instance', () => {
cy.get('.tiptap').then(([{ editor }]) => {
// eslint-disable-next-line
expect(editor).to.not.be.null
})
})
it('should have a ydoc', () => {
cy.get('.tiptap').then(([{ editor }]) => {
/**
* @type {import('yjs').Doc}
*/
const yDoc = editor.extensionManager.extensions.find(a => a.name === 'collaboration').options.document
// eslint-disable-next-line
expect(yDoc).to.not.be.null
})
})
})

View File

@ -3,5 +3,22 @@ context('/src/Extensions/Collaboration/React/', () => {
cy.visit('/src/Extensions/Collaboration/React/')
})
// TODO: Write tests
it('should have a working tiptap instance', () => {
cy.get('.tiptap').then(([{ editor }]) => {
// eslint-disable-next-line
expect(editor).to.not.be.null
})
})
it('should have a ydoc', () => {
cy.get('.tiptap').then(([{ editor }]) => {
/**
* @type {import('yjs').Doc}
*/
const yDoc = editor.extensionManager.extensions.find(a => a.name === 'collaboration').options.document
// eslint-disable-next-line
expect(yDoc).to.not.be.null
})
})
})

View File

@ -3,5 +3,22 @@ context('/src/Extensions/Collaboration/Vue/', () => {
cy.visit('/src/Extensions/Collaboration/Vue/')
})
// TODO: Write tests
it('should have a working tiptap instance', () => {
cy.get('.tiptap').then(([{ editor }]) => {
// eslint-disable-next-line
expect(editor).to.not.be.null
})
})
it('should have a ydoc', () => {
cy.get('.tiptap').then(([{ editor }]) => {
/**
* @type {import('yjs').Doc}
*/
const yDoc = editor.extensionManager.extensions.find(a => a.name === 'collaboration').options.document
// eslint-disable-next-line
expect(yDoc).to.not.be.null
})
})
})

View File

@ -1,7 +1,24 @@
context('/src/Extensions/CollaborationCursor/React', () => {
before(() => {
cy.visit('/src/Extensions/CollaborationCursor/React')
cy.visit('/src/Extensions/CollaborationCursor/React/')
})
// TODO: Write tests
it('should have a working tiptap instance', () => {
cy.get('.tiptap').then(([{ editor }]) => {
// eslint-disable-next-line
expect(editor).to.not.be.null
})
})
it('should have a ydoc', () => {
cy.get('.tiptap').then(([{ editor }]) => {
/**
* @type {import('yjs').Doc}
*/
const yDoc = editor.extensionManager.extensions.find(a => a.name === 'collaboration').options.document
// eslint-disable-next-line
expect(yDoc).to.not.be.null
})
})
})

View File

@ -1,7 +1,24 @@
context('/src/Extensions/CollaborationCursor/Vue', () => {
before(() => {
cy.visit('/src/Extensions/CollaborationCursor/Vue')
cy.visit('/src/Extensions/CollaborationCursor/Vue/')
})
// TODO: Write tests
it('should have a working tiptap instance', () => {
cy.get('.tiptap').then(([{ editor }]) => {
// eslint-disable-next-line
expect(editor).to.not.be.null
})
})
it('should have a ydoc', () => {
cy.get('.tiptap').then(([{ editor }]) => {
/**
* @type {import('yjs').Doc}
*/
const yDoc = editor.extensionManager.extensions.find(a => a.name === 'collaboration').options.document
// eslint-disable-next-line
expect(yDoc).to.not.be.null
})
})
})

View File

@ -21,6 +21,10 @@ const getPackageDependencies = () => {
find: 'yjs',
replacement: resolve('../node_modules/yjs/src/index.js'),
})
paths.push({
find: 'y-prosemirror',
replacement: resolve('../node_modules/y-prosemirror/src/y-prosemirror.js'),
})
fg.sync('../packages/*', { onlyDirectories: true })
.map(name => name.replace('../packages/', ''))

6157
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,11 @@ export class Editor extends EventEmitter<EditorEvents> {
public isFocused = false
/**
* The editor is considered initialized after the `create` event has been emitted.
*/
public isInitialized = false
public extensionStorage: Record<string, any> = {}
public options: EditorOptions = {
@ -111,6 +116,7 @@ export class Editor extends EventEmitter<EditorEvents> {
this.commands.focus(this.options.autofocus)
this.emit('create', { editor: this })
this.isInitialized = true
}, 0)
}

View File

@ -32,10 +32,10 @@ declare module '@tiptap/core' {
name: string
/**
* The priority of your extension. The higher, the later it will be called
* The priority of your extension. The higher, the earlier it will be called
* and will take precedence over other extensions with a lower priority.
* @default 1000
* @example 1001
* @default 100
* @example 101
*/
priority?: number
@ -339,6 +339,7 @@ declare module '@tiptap/core' {
parent: ParentConfig<ExtensionConfig<Options, Storage>>['onTransaction']
},
props: {
editor: Editor
transaction: Transaction
},
) => void)

View File

@ -35,10 +35,10 @@ declare module '@tiptap/core' {
name: string
/**
* The priority of your extension. The higher, the later it will be called
* The priority of your extension. The higher, the earlier it will be called
* and will take precedence over other extensions with a lower priority.
* @default 1000
* @example 1001
* @default 100
* @example 101
*/
priority?: number
@ -352,6 +352,7 @@ declare module '@tiptap/core' {
parent: ParentConfig<MarkConfig<Options, Storage>>['onTransaction']
},
props: {
editor: Editor
transaction: Transaction
},
) => void)

View File

@ -36,10 +36,10 @@ declare module '@tiptap/core' {
name: string
/**
* The priority of your extension. The higher, the later it will be called
* The priority of your extension. The higher, the earlier it will be called
* and will take precedence over other extensions with a lower priority.
* @default 1000
* @example 1001
* @default 100
* @example 101
*/
priority?: number
@ -354,6 +354,7 @@ declare module '@tiptap/core' {
parent: ParentConfig<NodeConfig<Options, Storage>>['onTransaction']
},
props: {
editor: Editor
transaction: Transaction
},
) => void)

View File

@ -1,3 +1,5 @@
import type { Plugin, PluginKey } from '@tiptap/pm/state'
import { RawCommands } from '../types.js'
declare module '@tiptap/core' {
@ -9,7 +11,7 @@ declare module '@tiptap/core' {
* @param value The value to store.
* @example editor.commands.setMeta('foo', 'bar')
*/
setMeta: (key: string, value: any) => ReturnType,
setMeta: (key: string | Plugin | PluginKey, value: any) => ReturnType,
}
}
}

View File

@ -14,14 +14,15 @@ declare module '@tiptap/core' {
/**
* Splits one list item into two list items.
* @param typeOrName The type or name of the node.
* @param overrideAttrs The attributes to ensure on the new node.
* @example editor.commands.splitListItem('listItem')
*/
splitListItem: (typeOrName: string | NodeType) => ReturnType
splitListItem: (typeOrName: string | NodeType, overrideAttrs?: Record<string, any>) => ReturnType
}
}
}
export const splitListItem: RawCommands['splitListItem'] = typeOrName => ({
export const splitListItem: RawCommands['splitListItem'] = (typeOrName, overrideAttrs = {}) => ({
tr, state, dispatch, editor,
}) => {
const type = getNodeType(typeOrName, state.schema)
@ -70,11 +71,14 @@ export const splitListItem: RawCommands['splitListItem'] = typeOrName => ({
const depthAfter = $from.indexAfter(-1) < $from.node(-2).childCount ? 1 : $from.indexAfter(-2) < $from.node(-3).childCount ? 2 : 3
// Add a second list item with an empty default start node
const newNextTypeAttributes = getSplittedAttributes(
extensionAttributes,
$from.node().type.name,
$from.node().attrs,
)
const newNextTypeAttributes = {
...getSplittedAttributes(
extensionAttributes,
$from.node().type.name,
$from.node().attrs,
),
...overrideAttrs,
}
const nextType = type.contentMatch.defaultType?.createAndFill(newNextTypeAttributes) || undefined
wrap = wrap.append(Fragment.from(type.createAndFill(null, nextType) || undefined))
@ -107,16 +111,22 @@ export const splitListItem: RawCommands['splitListItem'] = typeOrName => ({
const nextType = $to.pos === $from.end() ? grandParent.contentMatchAt(0).defaultType : null
const newTypeAttributes = getSplittedAttributes(
extensionAttributes,
grandParent.type.name,
grandParent.attrs,
)
const newNextTypeAttributes = getSplittedAttributes(
extensionAttributes,
$from.node().type.name,
$from.node().attrs,
)
const newTypeAttributes = {
...getSplittedAttributes(
extensionAttributes,
grandParent.type.name,
grandParent.attrs,
),
...overrideAttrs,
}
const newNextTypeAttributes = {
...getSplittedAttributes(
extensionAttributes,
$from.node().type.name,
$from.node().attrs,
),
...overrideAttrs,
}
tr.delete($from.pos, $to.pos)

View File

@ -1,7 +1,7 @@
import { NodeType } from '@tiptap/pm/model'
import { PasteRule, PasteRuleFinder } from '../PasteRule.js'
import { ExtendedRegExpMatchArray } from '../types.js'
import { ExtendedRegExpMatchArray, JSONContent } from '../types.js'
import { callOrReturn } from '../utilities/index.js'
/**
@ -17,6 +17,11 @@ export function nodePasteRule(config: {
| ((match: ExtendedRegExpMatchArray, event: ClipboardEvent) => Record<string, any>)
| false
| null
getContent?:
| JSONContent[]
| ((attrs: Record<string, any>) => JSONContent[])
| false
| null
}) {
return new PasteRule({
find: config.find,
@ -24,16 +29,20 @@ export function nodePasteRule(config: {
match, chain, range, pasteEvent,
}) {
const attributes = callOrReturn(config.getAttributes, undefined, match, pasteEvent)
const content = callOrReturn(config.getContent, undefined, attributes)
if (attributes === false || attributes === null) {
return null
}
const node = { type: config.type.name, attrs: attributes } as JSONContent
if (content) {
node.content = content
}
if (match.input) {
chain().deleteRange(range).insertContentAt(range.from, {
type: config.type.name,
attrs: attributes,
})
chain().deleteRange(range).insertContentAt(range.from, node)
}
},
})

View File

@ -27,8 +27,8 @@ img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
width: 1px !important;
height: 1px !important;
width: 0 !important;
height: 0 !important;
}
.ProseMirror-gapcursor {

View File

@ -96,6 +96,8 @@ const defaultOnUpdate = () => null
export const CollaborationCursor = Extension.create<CollaborationCursorOptions, CollaborationCursorStorage>({
name: 'collaborationCursor',
priority: 999,
addOptions() {
return {
provider: null,

View File

@ -164,6 +164,9 @@ export const Link = Mark.create<LinkOptions>({
return {
href: {
default: null,
parseHTML(element) {
return element.getAttribute('href')
},
},
target: {
default: this.options.HTMLAttributes.target,
@ -187,7 +190,7 @@ export const Link = Mark.create<LinkOptions>({
if (!href || !isAllowedUri(href)) {
return false
}
return { href }
return null
},
}]
},

View File

@ -84,6 +84,10 @@ export const OrderedList = Node.create<OrderedListOptions>({
: 1
},
},
type: {
default: null,
parseHTML: element => element.getAttribute('type'),
},
}
},

View File

@ -33,6 +33,8 @@ declare module '@tiptap/core' {
export const TextStyle = Mark.create<TextStyleOptions>({
name: 'textStyle',
priority: 101,
addOptions() {
return {
HTMLAttributes: {},

View File

@ -1,6 +1,6 @@
import { Editor } from '@tiptap/core'
import React, { createContext, ReactNode, useContext } from 'react'
import { Editor } from './Editor.js'
import { EditorContent } from './EditorContent.js'
import { useEditor, UseEditorOptions } from './useEditor.js'

View File

@ -1,14 +1,13 @@
import { Editor as CoreEditor } from '@tiptap/core'
import React from 'react'
import { Editor } from '@tiptap/core'
import { ReactPortal } from 'react'
import { EditorContentProps, EditorContentState } from './EditorContent.js'
import { ReactRenderer } from './ReactRenderer.js'
type ContentComponent = React.Component<EditorContentProps, EditorContentState> & {
export type EditorWithContentComponent = Editor & { contentComponent?: ContentComponent | null }
export type ContentComponent = {
setRenderer(id: string, renderer: ReactRenderer): void;
removeRenderer(id: string): void;
}
export class Editor extends CoreEditor {
public contentComponent: ContentComponent | null = null
subscribe: (callback: () => void) => () => void;
getSnapshot: () => Record<string, ReactPortal>;
getServerSnapshot: () => Record<string, ReactPortal>;
}

View File

@ -1,9 +1,11 @@
import { Editor } from '@tiptap/core'
import React, {
ForwardedRef, forwardRef, HTMLProps, LegacyRef, MutableRefObject,
} from 'react'
import ReactDOM, { flushSync } from 'react-dom'
import ReactDOM from 'react-dom'
import { useSyncExternalStore } from 'use-sync-external-store/shim'
import { Editor } from './Editor.js'
import { ContentComponent, EditorWithContentComponent } from './Editor.js'
import { ReactRenderer } from './ReactRenderer.js'
const mergeRefs = <T extends HTMLDivElement>(
@ -20,12 +22,23 @@ const mergeRefs = <T extends HTMLDivElement>(
}
}
const Portals: React.FC<{ renderers: Record<string, ReactRenderer> }> = ({ renderers }) => {
/**
* 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.entries(renderers).map(([key, renderer]) => {
return ReactDOM.createPortal(renderer.reactElement, renderer.element, key)
})}
{Object.values(renderers)}
</>
)
}
@ -35,22 +48,67 @@ export interface EditorContentProps extends HTMLProps<HTMLDivElement> {
innerRef?: ForwardedRef<HTMLDivElement | null>;
}
export interface EditorContentState {
renderers: Record<string, ReactRenderer>;
function getInstance(): ContentComponent {
const subscribers = new Set<() => void>()
let renderers: Record<string, React.ReactPortal> = {}
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),
}
subscribers.forEach(subscriber => subscriber())
},
/**
* Removes a NodeView Renderer from the editor.
*/
removeRenderer(id: string) {
const nextRenderers = { ...renderers }
delete nextRenderers[id]
renderers = nextRenderers
subscribers.forEach(subscriber => subscriber())
},
}
}
export class PureEditorContent extends React.Component<EditorContentProps, EditorContentState> {
export class PureEditorContent extends React.Component<
EditorContentProps,
{ hasContentComponentInitialized: boolean }
> {
editorContentRef: React.RefObject<any>
initialized: boolean
unsubscribeToContentComponent?: () => void
constructor(props: EditorContentProps) {
super(props)
this.editorContentRef = React.createRef()
this.initialized = false
this.state = {
renderers: {},
hasContentComponentInitialized: Boolean((props.editor as EditorWithContentComponent | null)?.contentComponent),
}
}
@ -63,7 +121,7 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
}
init() {
const { editor } = this.props
const editor = this.props.editor as EditorWithContentComponent | null
if (editor && !editor.isDestroyed && editor.options.element) {
if (editor.contentComponent) {
@ -78,7 +136,27 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
element,
})
editor.contentComponent = this
editor.contentComponent = getInstance()
// Has the content component been initialized?
if (!this.state.hasContentComponentInitialized) {
// Subscribe to the content component
this.unsubscribeToContentComponent = editor.contentComponent.subscribe(() => {
this.setState(prevState => {
if (!prevState.hasContentComponentInitialized) {
return {
hasContentComponentInitialized: true,
}
}
return prevState
})
// Unsubscribe to previous content component
if (this.unsubscribeToContentComponent) {
this.unsubscribeToContentComponent()
}
})
}
editor.createNodeViews()
@ -86,43 +164,8 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
}
}
maybeFlushSync(fn: () => void) {
// Avoid calling flushSync until the editor is initialized.
// Initialization happens during the componentDidMount or componentDidUpdate
// lifecycle methods, and React doesn't allow calling flushSync from inside
// a lifecycle method.
if (this.initialized) {
flushSync(fn)
} else {
fn()
}
}
setRenderer(id: string, renderer: ReactRenderer) {
this.maybeFlushSync(() => {
this.setState(({ renderers }) => ({
renderers: {
...renderers,
[id]: renderer,
},
}))
})
}
removeRenderer(id: string) {
this.maybeFlushSync(() => {
this.setState(({ renderers }) => {
const nextRenderers = { ...renderers }
delete nextRenderers[id]
return { renderers: nextRenderers }
})
})
}
componentWillUnmount() {
const { editor } = this.props
const editor = this.props.editor as EditorWithContentComponent | null
if (!editor) {
return
@ -136,6 +179,10 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
})
}
if (this.unsubscribeToContentComponent) {
this.unsubscribeToContentComponent()
}
editor.contentComponent = null
if (!editor.options.element.firstChild) {
@ -158,7 +205,7 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
<>
<div ref={mergeRefs(innerRef, this.editorContentRef)} {...rest} />
{/* @ts-ignore */}
<Portals renderers={this.state.renderers} />
{editor?.contentComponent && <Portals contentComponent={editor.contentComponent} />}
</>
)
}
@ -168,7 +215,8 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
const EditorContentWithKey = forwardRef<HTMLDivElement, EditorContentProps>(
(props: Omit<EditorContentProps, 'innerRef'>, ref) => {
const key = React.useMemo(() => {
return Math.floor(Math.random() * 0xFFFFFFFF).toString()
return Math.floor(Math.random() * 0xffffffff).toString()
// eslint-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

View File

@ -12,6 +12,7 @@ export const NodeViewContent: React.FC<NodeViewContentProps> = props => {
const { nodeViewContentRef } = useReactNodeView()
return (
// @ts-ignore
<Tag
{...props}
ref={nodeViewContentRef}

View File

@ -12,6 +12,7 @@ export const NodeViewWrapper: React.FC<NodeViewWrapperProps> = React.forwardRef(
const Tag = props.as || 'div'
return (
// @ts-ignore
<Tag
{...props}
ref={ref}

View File

@ -1,5 +1,6 @@
import {
DecorationWithType,
Editor,
NodeView,
NodeViewProps,
NodeViewRenderer,
@ -10,7 +11,7 @@ import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Decoration, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
import React from 'react'
import { Editor } from './Editor.js'
import { EditorWithContentComponent } from './Editor.js'
import { ReactRenderer } from './ReactRenderer.js'
import { ReactNodeViewContext, ReactNodeViewContextProps } from './useReactNodeView.js'
@ -58,25 +59,23 @@ class ReactNodeView extends NodeView<
this.component.displayName = capitalizeFirstChar(this.extension.name)
}
const ReactNodeViewProvider: React.FunctionComponent = componentProps => {
const Component = this.component
const onDragStart = this.onDragStart.bind(this)
const nodeViewContentRef: ReactNodeViewContextProps['nodeViewContentRef'] = element => {
if (element && this.contentDOMElement && element.firstChild !== this.contentDOMElement) {
element.appendChild(this.contentDOMElement)
}
const onDragStart = this.onDragStart.bind(this)
const nodeViewContentRef: ReactNodeViewContextProps['nodeViewContentRef'] = element => {
if (element && this.contentDOMElement && element.firstChild !== this.contentDOMElement) {
element.appendChild(this.contentDOMElement)
}
return (
<>
{/* @ts-ignore */}
<ReactNodeViewContext.Provider value={{ onDragStart, nodeViewContentRef }}>
{/* @ts-ignore */}
<Component {...componentProps} />
</ReactNodeViewContext.Provider>
</>
)
}
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: React.FunctionComponent = React.memo(componentProps => {
return (
<ReactNodeViewContext.Provider value={context}>
{React.createElement(Component, componentProps)}
</ReactNodeViewContext.Provider>
)
})
ReactNodeViewProvider.displayName = 'ReactNodeView'
@ -218,7 +217,7 @@ export function ReactNodeViewRenderer(
// try to get the parent component
// this is important for vue devtools to show the component hierarchy correctly
// maybe its `undefined` because <editor-content> isnt rendered yet
if (!(props.editor as Editor).contentComponent) {
if (!(props.editor as EditorWithContentComponent).contentComponent) {
return {}
}

View File

@ -1,7 +1,8 @@
import { Editor } from '@tiptap/core'
import React from 'react'
import { flushSync } from 'react-dom'
import { Editor as ExtendedEditor } from './Editor.js'
import { EditorWithContentComponent } from './Editor.js'
/**
* Check if a component is a class component.
@ -85,7 +86,7 @@ type ComponentType<R, P> =
export class ReactRenderer<R = unknown, P = unknown> {
id: string
editor: ExtendedEditor
editor: Editor
component: any
@ -106,7 +107,7 @@ export class ReactRenderer<R = unknown, P = unknown> {
}: ReactRendererOptions) {
this.id = Math.floor(Math.random() * 0xFFFFFFFF).toString()
this.component = component
this.editor = editor as ExtendedEditor
this.editor = editor as EditorWithContentComponent
this.props = props
this.element = document.createElement(as)
this.element.classList.add('react-renderer')
@ -121,12 +122,21 @@ export class ReactRenderer<R = unknown, P = unknown> {
})
}
this.render()
if (this.editor.isInitialized) {
// On first render, we need to flush the render synchronously
// Renders afterwards can be async, but this fixes a cursor positioning issue
flushSync(() => {
this.render()
})
} else {
this.render()
}
}
render(): void {
const Component = this.component
const props = this.props
const editor = this.editor as EditorWithContentComponent
if (isClassComponent(Component) || isForwardRefComponent(Component)) {
props.ref = (ref: R) => {
@ -134,9 +144,9 @@ export class ReactRenderer<R = unknown, P = unknown> {
}
}
this.reactElement = <Component {...props } />
this.reactElement = React.createElement(Component, props)
this.editor?.contentComponent?.setRenderer(this.id, this)
editor?.contentComponent?.setRenderer(this.id, this)
}
updateProps(props: Record<string, any> = {}): void {
@ -149,6 +159,8 @@ export class ReactRenderer<R = unknown, P = unknown> {
}
destroy(): void {
this.editor?.contentComponent?.removeRenderer(this.id)
const editor = this.editor as EditorWithContentComponent
editor?.contentComponent?.removeRenderer(this.id)
}
}

View File

@ -1,6 +1,5 @@
export * from './BubbleMenu.js'
export * from './Context.js'
export { Editor } from './Editor.js'
export * from './EditorContent.js'
export * from './FloatingMenu.js'
export * from './NodeViewContent.js'

View File

@ -1,4 +1,4 @@
import { EditorOptions } from '@tiptap/core'
import { type EditorOptions, Editor } from '@tiptap/core'
import {
DependencyList,
MutableRefObject,
@ -9,7 +9,6 @@ import {
} from 'react'
import { useSyncExternalStore } from 'use-sync-external-store/shim'
import { Editor } from './Editor.js'
import { useEditorState } from './useEditorState.js'
const isDev = process.env.NODE_ENV !== 'production'
@ -136,18 +135,20 @@ class EditorInstanceManager {
* Create a new editor instance. And attach event listeners.
*/
private createEditor(): Editor {
const editor = new Editor(this.options.current)
// Always call the most recent version of the callback function by default
editor.on('beforeCreate', (...args) => this.options.current.onBeforeCreate?.(...args))
editor.on('blur', (...args) => this.options.current.onBlur?.(...args))
editor.on('create', (...args) => this.options.current.onCreate?.(...args))
editor.on('destroy', (...args) => this.options.current.onDestroy?.(...args))
editor.on('focus', (...args) => this.options.current.onFocus?.(...args))
editor.on('selectionUpdate', (...args) => this.options.current.onSelectionUpdate?.(...args))
editor.on('transaction', (...args) => this.options.current.onTransaction?.(...args))
editor.on('update', (...args) => this.options.current.onUpdate?.(...args))
editor.on('contentError', (...args) => this.options.current.onContentError?.(...args))
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),
}
const editor = new Editor(optionsToApply)
// no need to keep track of the event listeners, they will be removed when the editor is destroyed
@ -215,7 +216,6 @@ class EditorInstanceManager {
* 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) {

View File

@ -1,8 +1,7 @@
import type { Editor } from '@tiptap/core'
import { useDebugValue, useEffect, useState } from 'react'
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'
import type { Editor } from './Editor.js'
export type EditorStateSnapshot<TEditor extends Editor | null = Editor | null> = {
editor: TEditor;
transactionNumber: number;

View File

@ -49,7 +49,8 @@
"@tiptap/extension-paragraph": "^3.0.0-next.0",
"@tiptap/extension-strike": "^3.0.0-next.0",
"@tiptap/extension-underline": "^3.0.0-next.0",
"@tiptap/extension-text": "^3.0.0-next.0"
"@tiptap/extension-text": "^3.0.0-next.0",
"@tiptap/pm": "^3.0.0-next.0"
},
"repository": {
"type": "git",

View File

@ -221,7 +221,12 @@ export function VueNodeViewRenderer(
if (!(props.editor as Editor).contentComponent) {
return {}
}
// check for class-component and normalize if neccessary
const normalizedComponent = typeof component === 'function' && '__vccOpts' in component
// eslint-disable-next-line no-underscore-dangle
? component.__vccOpts as Component
: component
return new VueNodeView(component, props, options) as unknown as ProseMirrorNodeView
return new VueNodeView(normalizedComponent, props, options) as unknown as ProseMirrorNodeView
}
}