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

View File

@ -1,6 +1,6 @@
MIT License 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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal 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> </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 ### 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. 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
react-dom react-dom
react-dom/client react-dom/client
use-sync-external-store/shim
use-sync-external-store/shim/with-selector use-sync-external-store/shim/with-selector
shiki shiki
simplify-js simplify-js
@ -27,3 +28,5 @@ simplify-js
uuid uuid
y-webrtc y-webrtc
yjs 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" "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": { "dependencies": {
"@hocuspocus/provider": "^2.13.5", "@hocuspocus/provider": "2.13.5",
"@lexical/react": "^0.11.1", "@lexical/react": "^0.11.1",
"@shikijs/core": "1.10.3",
"d3": "^7.3.0", "d3": "^7.3.0",
"fast-glob": "^3.2.11", "fast-glob": "^3.2.11",
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",
@ -20,16 +21,17 @@
"remixicon": "^2.5.0", "remixicon": "^2.5.0",
"shiki": "^1.10.3", "shiki": "^1.10.3",
"simplify-js": "^1.2.4", "simplify-js": "^1.2.4",
"y-prosemirror": "^1.2.11", "y-prosemirror": "1.2.11",
"y-webrtc": "^10.3.0", "y-webrtc": "^10.3.0",
"yjs": "^13.6.18" "yjs": "13.6.18"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"@vitejs/plugin-react": "^1.3.2", "@vitejs/plugin-react": "^1.3.2",
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.0.0",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"esbuild": "0.21.5",
"iframe-resizer": "^4.3.2", "iframe-resizer": "^4.3.2",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"postcss-import": "^15.1.0", "postcss-import": "^15.1.0",
@ -37,7 +39,7 @@
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"sass": "^1.49.7", "sass": "^1.49.7",
"svelte": "^3.49.0", "svelte": "^4.0.0",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"uuid": "^8.3.2", "uuid": "^8.3.2",

View File

@ -3,5 +3,22 @@ context('/src/Demos/CollaborationSplitPane/React/', () => {
cy.visit('/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/') 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/') 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', () => { context('/src/Extensions/CollaborationCursor/React', () => {
before(() => { 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', () => { context('/src/Extensions/CollaborationCursor/Vue', () => {
before(() => { 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', find: 'yjs',
replacement: resolve('../node_modules/yjs/src/index.js'), 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 }) fg.sync('../packages/*', { onlyDirectories: true })
.map(name => name.replace('../packages/', '')) .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 public isFocused = false
/**
* The editor is considered initialized after the `create` event has been emitted.
*/
public isInitialized = false
public extensionStorage: Record<string, any> = {} public extensionStorage: Record<string, any> = {}
public options: EditorOptions = { public options: EditorOptions = {
@ -111,6 +116,7 @@ export class Editor extends EventEmitter<EditorEvents> {
this.commands.focus(this.options.autofocus) this.commands.focus(this.options.autofocus)
this.emit('create', { editor: this }) this.emit('create', { editor: this })
this.isInitialized = true
}, 0) }, 0)
} }

View File

@ -32,10 +32,10 @@ declare module '@tiptap/core' {
name: string 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. * and will take precedence over other extensions with a lower priority.
* @default 1000 * @default 100
* @example 1001 * @example 101
*/ */
priority?: number priority?: number
@ -339,6 +339,7 @@ declare module '@tiptap/core' {
parent: ParentConfig<ExtensionConfig<Options, Storage>>['onTransaction'] parent: ParentConfig<ExtensionConfig<Options, Storage>>['onTransaction']
}, },
props: { props: {
editor: Editor
transaction: Transaction transaction: Transaction
}, },
) => void) ) => void)

View File

@ -35,10 +35,10 @@ declare module '@tiptap/core' {
name: string 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. * and will take precedence over other extensions with a lower priority.
* @default 1000 * @default 100
* @example 1001 * @example 101
*/ */
priority?: number priority?: number
@ -352,6 +352,7 @@ declare module '@tiptap/core' {
parent: ParentConfig<MarkConfig<Options, Storage>>['onTransaction'] parent: ParentConfig<MarkConfig<Options, Storage>>['onTransaction']
}, },
props: { props: {
editor: Editor
transaction: Transaction transaction: Transaction
}, },
) => void) ) => void)

View File

@ -36,10 +36,10 @@ declare module '@tiptap/core' {
name: string 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. * and will take precedence over other extensions with a lower priority.
* @default 1000 * @default 100
* @example 1001 * @example 101
*/ */
priority?: number priority?: number
@ -354,6 +354,7 @@ declare module '@tiptap/core' {
parent: ParentConfig<NodeConfig<Options, Storage>>['onTransaction'] parent: ParentConfig<NodeConfig<Options, Storage>>['onTransaction']
}, },
props: { props: {
editor: Editor
transaction: Transaction transaction: Transaction
}, },
) => void) ) => void)

View File

@ -1,3 +1,5 @@
import type { Plugin, PluginKey } from '@tiptap/pm/state'
import { RawCommands } from '../types.js' import { RawCommands } from '../types.js'
declare module '@tiptap/core' { declare module '@tiptap/core' {
@ -9,7 +11,7 @@ declare module '@tiptap/core' {
* @param value The value to store. * @param value The value to store.
* @example editor.commands.setMeta('foo', 'bar') * @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. * Splits one list item into two list items.
* @param typeOrName The type or name of the node. * @param typeOrName The type or name of the node.
* @param overrideAttrs The attributes to ensure on the new node.
* @example editor.commands.splitListItem('listItem') * @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, tr, state, dispatch, editor,
}) => { }) => {
const type = getNodeType(typeOrName, state.schema) 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 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 // Add a second list item with an empty default start node
const newNextTypeAttributes = getSplittedAttributes( const newNextTypeAttributes = {
...getSplittedAttributes(
extensionAttributes, extensionAttributes,
$from.node().type.name, $from.node().type.name,
$from.node().attrs, $from.node().attrs,
) ),
...overrideAttrs,
}
const nextType = type.contentMatch.defaultType?.createAndFill(newNextTypeAttributes) || undefined const nextType = type.contentMatch.defaultType?.createAndFill(newNextTypeAttributes) || undefined
wrap = wrap.append(Fragment.from(type.createAndFill(null, nextType) || 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 nextType = $to.pos === $from.end() ? grandParent.contentMatchAt(0).defaultType : null
const newTypeAttributes = getSplittedAttributes( const newTypeAttributes = {
...getSplittedAttributes(
extensionAttributes, extensionAttributes,
grandParent.type.name, grandParent.type.name,
grandParent.attrs, grandParent.attrs,
) ),
const newNextTypeAttributes = getSplittedAttributes( ...overrideAttrs,
}
const newNextTypeAttributes = {
...getSplittedAttributes(
extensionAttributes, extensionAttributes,
$from.node().type.name, $from.node().type.name,
$from.node().attrs, $from.node().attrs,
) ),
...overrideAttrs,
}
tr.delete($from.pos, $to.pos) tr.delete($from.pos, $to.pos)

View File

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

View File

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

View File

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

View File

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

View File

@ -84,6 +84,10 @@ export const OrderedList = Node.create<OrderedListOptions>({
: 1 : 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>({ export const TextStyle = Mark.create<TextStyleOptions>({
name: 'textStyle', name: 'textStyle',
priority: 101,
addOptions() { addOptions() {
return { return {
HTMLAttributes: {}, HTMLAttributes: {},

View File

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

View File

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

View File

@ -1,9 +1,11 @@
import { Editor } from '@tiptap/core'
import React, { import React, {
ForwardedRef, forwardRef, HTMLProps, LegacyRef, MutableRefObject, ForwardedRef, forwardRef, HTMLProps, LegacyRef, MutableRefObject,
} from 'react' } 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' import { ReactRenderer } from './ReactRenderer.js'
const mergeRefs = <T extends HTMLDivElement>( 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 ( return (
<> <>
{Object.entries(renderers).map(([key, renderer]) => { {Object.values(renderers)}
return ReactDOM.createPortal(renderer.reactElement, renderer.element, key)
})}
</> </>
) )
} }
@ -35,22 +48,67 @@ export interface EditorContentProps extends HTMLProps<HTMLDivElement> {
innerRef?: ForwardedRef<HTMLDivElement | null>; innerRef?: ForwardedRef<HTMLDivElement | null>;
} }
export interface EditorContentState { function getInstance(): ContentComponent {
renderers: Record<string, ReactRenderer>; 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> editorContentRef: React.RefObject<any>
initialized: boolean initialized: boolean
unsubscribeToContentComponent?: () => void
constructor(props: EditorContentProps) { constructor(props: EditorContentProps) {
super(props) super(props)
this.editorContentRef = React.createRef() this.editorContentRef = React.createRef()
this.initialized = false this.initialized = false
this.state = { this.state = {
renderers: {}, hasContentComponentInitialized: Boolean((props.editor as EditorWithContentComponent | null)?.contentComponent),
} }
} }
@ -63,7 +121,7 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
} }
init() { init() {
const { editor } = this.props const editor = this.props.editor as EditorWithContentComponent | null
if (editor && !editor.isDestroyed && editor.options.element) { if (editor && !editor.isDestroyed && editor.options.element) {
if (editor.contentComponent) { if (editor.contentComponent) {
@ -78,7 +136,27 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
element, 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() 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() { componentWillUnmount() {
const { editor } = this.props const editor = this.props.editor as EditorWithContentComponent | null
if (!editor) { if (!editor) {
return return
@ -136,6 +179,10 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
}) })
} }
if (this.unsubscribeToContentComponent) {
this.unsubscribeToContentComponent()
}
editor.contentComponent = null editor.contentComponent = null
if (!editor.options.element.firstChild) { if (!editor.options.element.firstChild) {
@ -158,7 +205,7 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
<> <>
<div ref={mergeRefs(innerRef, this.editorContentRef)} {...rest} /> <div ref={mergeRefs(innerRef, this.editorContentRef)} {...rest} />
{/* @ts-ignore */} {/* @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>( const EditorContentWithKey = forwardRef<HTMLDivElement, EditorContentProps>(
(props: Omit<EditorContentProps, 'innerRef'>, ref) => { (props: Omit<EditorContentProps, 'innerRef'>, ref) => {
const key = React.useMemo(() => { 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]) }, [props.editor])
// Can't use JSX here because it conflicts with the type definition of Vue's JSX, so use createElement // 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() const { nodeViewContentRef } = useReactNodeView()
return ( return (
// @ts-ignore
<Tag <Tag
{...props} {...props}
ref={nodeViewContentRef} ref={nodeViewContentRef}

View File

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

View File

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

View File

@ -1,7 +1,8 @@
import { Editor } from '@tiptap/core' import { Editor } from '@tiptap/core'
import React from 'react' 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. * Check if a component is a class component.
@ -85,7 +86,7 @@ type ComponentType<R, P> =
export class ReactRenderer<R = unknown, P = unknown> { export class ReactRenderer<R = unknown, P = unknown> {
id: string id: string
editor: ExtendedEditor editor: Editor
component: any component: any
@ -106,7 +107,7 @@ export class ReactRenderer<R = unknown, P = unknown> {
}: ReactRendererOptions) { }: ReactRendererOptions) {
this.id = Math.floor(Math.random() * 0xFFFFFFFF).toString() this.id = Math.floor(Math.random() * 0xFFFFFFFF).toString()
this.component = component this.component = component
this.editor = editor as ExtendedEditor this.editor = editor as EditorWithContentComponent
this.props = props this.props = props
this.element = document.createElement(as) this.element = document.createElement(as)
this.element.classList.add('react-renderer') this.element.classList.add('react-renderer')
@ -121,12 +122,21 @@ export class ReactRenderer<R = unknown, P = unknown> {
}) })
} }
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() this.render()
})
} else {
this.render()
}
} }
render(): void { render(): void {
const Component = this.component const Component = this.component
const props = this.props const props = this.props
const editor = this.editor as EditorWithContentComponent
if (isClassComponent(Component) || isForwardRefComponent(Component)) { if (isClassComponent(Component) || isForwardRefComponent(Component)) {
props.ref = (ref: R) => { 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 { updateProps(props: Record<string, any> = {}): void {
@ -149,6 +159,8 @@ export class ReactRenderer<R = unknown, P = unknown> {
} }
destroy(): void { 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 './BubbleMenu.js'
export * from './Context.js' export * from './Context.js'
export { Editor } from './Editor.js'
export * from './EditorContent.js' export * from './EditorContent.js'
export * from './FloatingMenu.js' export * from './FloatingMenu.js'
export * from './NodeViewContent.js' export * from './NodeViewContent.js'

View File

@ -1,4 +1,4 @@
import { EditorOptions } from '@tiptap/core' import { type EditorOptions, Editor } from '@tiptap/core'
import { import {
DependencyList, DependencyList,
MutableRefObject, MutableRefObject,
@ -9,7 +9,6 @@ import {
} from 'react' } from 'react'
import { useSyncExternalStore } from 'use-sync-external-store/shim' import { useSyncExternalStore } from 'use-sync-external-store/shim'
import { Editor } from './Editor.js'
import { useEditorState } from './useEditorState.js' import { useEditorState } from './useEditorState.js'
const isDev = process.env.NODE_ENV !== 'production' const isDev = process.env.NODE_ENV !== 'production'
@ -136,18 +135,20 @@ class EditorInstanceManager {
* Create a new editor instance. And attach event listeners. * Create a new editor instance. And attach event listeners.
*/ */
private createEditor(): Editor { private createEditor(): Editor {
const editor = new Editor(this.options.current) const optionsToApply: Partial<EditorOptions> = {
...this.options.current,
// Always call the most recent version of the callback function by default // Always call the most recent version of the callback function by default
editor.on('beforeCreate', (...args) => this.options.current.onBeforeCreate?.(...args)) onBeforeCreate: (...args) => this.options.current.onBeforeCreate?.(...args),
editor.on('blur', (...args) => this.options.current.onBlur?.(...args)) onBlur: (...args) => this.options.current.onBlur?.(...args),
editor.on('create', (...args) => this.options.current.onCreate?.(...args)) onCreate: (...args) => this.options.current.onCreate?.(...args),
editor.on('destroy', (...args) => this.options.current.onDestroy?.(...args)) onDestroy: (...args) => this.options.current.onDestroy?.(...args),
editor.on('focus', (...args) => this.options.current.onFocus?.(...args)) onFocus: (...args) => this.options.current.onFocus?.(...args),
editor.on('selectionUpdate', (...args) => this.options.current.onSelectionUpdate?.(...args)) onSelectionUpdate: (...args) => this.options.current.onSelectionUpdate?.(...args),
editor.on('transaction', (...args) => this.options.current.onTransaction?.(...args)) onTransaction: (...args) => this.options.current.onTransaction?.(...args),
editor.on('update', (...args) => this.options.current.onUpdate?.(...args)) onUpdate: (...args) => this.options.current.onUpdate?.(...args),
editor.on('contentError', (...args) => this.options.current.onContentError?.(...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 // 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. * Recreate the editor instance if the dependencies have changed.
*/ */
private refreshEditorInstance(deps: DependencyList) { private refreshEditorInstance(deps: DependencyList) {
if (this.editor && !this.editor.isDestroyed) { if (this.editor && !this.editor.isDestroyed) {
// Editor instance already exists // Editor instance already exists
if (this.previousDeps === null) { if (this.previousDeps === null) {

View File

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

View File

@ -49,7 +49,8 @@
"@tiptap/extension-paragraph": "^3.0.0-next.0", "@tiptap/extension-paragraph": "^3.0.0-next.0",
"@tiptap/extension-strike": "^3.0.0-next.0", "@tiptap/extension-strike": "^3.0.0-next.0",
"@tiptap/extension-underline": "^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": { "repository": {
"type": "git", "type": "git",

View File

@ -221,7 +221,12 @@ export function VueNodeViewRenderer(
if (!(props.editor as Editor).contentComponent) { if (!(props.editor as Editor).contentComponent) {
return {} 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
} }
} }