mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-07 01:12:56 +08:00
Merge branch 'develop' into next
This commit is contained in:
commit
06ffa88cf6
5
.changeset/pink-bobcats-grin.md
Normal file
5
.changeset/pink-bobcats-grin.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/core": patch
|
||||
---
|
||||
|
||||
Resolve several selection related bug #2690 #5208
|
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@ -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
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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
6157
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
@ -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 {
|
||||
|
@ -96,6 +96,8 @@ const defaultOnUpdate = () => null
|
||||
export const CollaborationCursor = Extension.create<CollaborationCursorOptions, CollaborationCursorStorage>({
|
||||
name: 'collaborationCursor',
|
||||
|
||||
priority: 999,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
provider: null,
|
||||
|
@ -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
|
||||
},
|
||||
}]
|
||||
},
|
||||
|
@ -84,6 +84,10 @@ export const OrderedList = Node.create<OrderedListOptions>({
|
||||
: 1
|
||||
},
|
||||
},
|
||||
type: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('type'),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -33,6 +33,8 @@ declare module '@tiptap/core' {
|
||||
export const TextStyle = Mark.create<TextStyleOptions>({
|
||||
name: 'textStyle',
|
||||
|
||||
priority: 101,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -12,6 +12,7 @@ export const NodeViewContent: React.FC<NodeViewContentProps> = props => {
|
||||
const { nodeViewContentRef } = useReactNodeView()
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<Tag
|
||||
{...props}
|
||||
ref={nodeViewContentRef}
|
||||
|
@ -12,6 +12,7 @@ export const NodeViewWrapper: React.FC<NodeViewWrapperProps> = React.forwardRef(
|
||||
const Tag = props.as || 'div'
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<Tag
|
||||
{...props}
|
||||
ref={ref}
|
||||
|
@ -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 it’s `undefined` because <editor-content> isn’t rendered yet
|
||||
if (!(props.editor as Editor).contentComponent) {
|
||||
if (!(props.editor as EditorWithContentComponent).contentComponent) {
|
||||
return {}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user