@blockslides/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +36 -0
- package/dist/index.cjs +1100 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +349 -0
- package/dist/index.d.ts +349 -0
- package/dist/index.js +1042 -0
- package/dist/index.js.map +1 -0
- package/dist/menus/index.cjs +166 -0
- package/dist/menus/index.cjs.map +1 -0
- package/dist/menus/index.d.cts +19 -0
- package/dist/menus/index.d.ts +19 -0
- package/dist/menus/index.js +128 -0
- package/dist/menus/index.js.map +1 -0
- package/package.json +75 -0
- package/src/Context.tsx +60 -0
- package/src/Editor.ts +15 -0
- package/src/EditorContent.tsx +229 -0
- package/src/NodeViewContent.tsx +30 -0
- package/src/NodeViewWrapper.tsx +27 -0
- package/src/ReactMarkViewRenderer.tsx +106 -0
- package/src/ReactNodeViewRenderer.tsx +344 -0
- package/src/ReactRenderer.tsx +265 -0
- package/src/index.ts +12 -0
- package/src/menus/BubbleMenu.tsx +89 -0
- package/src/menus/FloatingMenu.tsx +69 -0
- package/src/menus/index.ts +2 -0
- package/src/types.ts +6 -0
- package/src/useEditor.ts +405 -0
- package/src/useEditorState.ts +188 -0
- package/src/useReactNodeView.ts +28 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DecorationWithType,
|
|
3
|
+
Editor,
|
|
4
|
+
NodeViewRenderer,
|
|
5
|
+
NodeViewRendererOptions,
|
|
6
|
+
NodeViewRendererProps,
|
|
7
|
+
} from '@blockslides/core'
|
|
8
|
+
import { getRenderedAttributes, NodeView } from '@blockslides/core'
|
|
9
|
+
import type { Node, Node as ProseMirrorNode } from '@blockslides/pm/model'
|
|
10
|
+
import type { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@blockslides/pm/view'
|
|
11
|
+
import type { ComponentType, NamedExoticComponent } from 'react'
|
|
12
|
+
import { createElement, createRef, memo } from 'react'
|
|
13
|
+
|
|
14
|
+
import type { EditorWithContentComponent } from './Editor.js'
|
|
15
|
+
import { ReactRenderer } from './ReactRenderer.js'
|
|
16
|
+
import type { ReactNodeViewProps } from './types.js'
|
|
17
|
+
import type { ReactNodeViewContextProps } from './useReactNodeView.js'
|
|
18
|
+
import { ReactNodeViewContext } from './useReactNodeView.js'
|
|
19
|
+
|
|
20
|
+
export interface ReactNodeViewRendererOptions extends NodeViewRendererOptions {
|
|
21
|
+
/**
|
|
22
|
+
* This function is called when the node view is updated.
|
|
23
|
+
* It allows you to compare the old node with the new node and decide if the component should update.
|
|
24
|
+
*/
|
|
25
|
+
update:
|
|
26
|
+
| ((props: {
|
|
27
|
+
oldNode: ProseMirrorNode
|
|
28
|
+
oldDecorations: readonly Decoration[]
|
|
29
|
+
oldInnerDecorations: DecorationSource
|
|
30
|
+
newNode: ProseMirrorNode
|
|
31
|
+
newDecorations: readonly Decoration[]
|
|
32
|
+
innerDecorations: DecorationSource
|
|
33
|
+
updateProps: () => void
|
|
34
|
+
}) => boolean)
|
|
35
|
+
| null
|
|
36
|
+
/**
|
|
37
|
+
* The tag name of the element wrapping the React component.
|
|
38
|
+
*/
|
|
39
|
+
as?: string
|
|
40
|
+
/**
|
|
41
|
+
* The class name of the element wrapping the React component.
|
|
42
|
+
*/
|
|
43
|
+
className?: string
|
|
44
|
+
/**
|
|
45
|
+
* Attributes that should be applied to the element wrapping the React component.
|
|
46
|
+
* If this is a function, it will be called each time the node view is updated.
|
|
47
|
+
* If this is an object, it will be applied once when the node view is mounted.
|
|
48
|
+
*/
|
|
49
|
+
attrs?:
|
|
50
|
+
| Record<string, string>
|
|
51
|
+
| ((props: { node: ProseMirrorNode; HTMLAttributes: Record<string, any> }) => Record<string, string>)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ReactNodeView<
|
|
55
|
+
T = HTMLElement,
|
|
56
|
+
Component extends ComponentType<ReactNodeViewProps<T>> = ComponentType<ReactNodeViewProps<T>>,
|
|
57
|
+
NodeEditor extends Editor = Editor,
|
|
58
|
+
Options extends ReactNodeViewRendererOptions = ReactNodeViewRendererOptions,
|
|
59
|
+
> extends NodeView<Component, NodeEditor, Options> {
|
|
60
|
+
/**
|
|
61
|
+
* The renderer instance.
|
|
62
|
+
*/
|
|
63
|
+
renderer!: ReactRenderer<unknown, ReactNodeViewProps<T>>
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The element that holds the rich-text content of the node.
|
|
67
|
+
*/
|
|
68
|
+
contentDOMElement!: HTMLElement | null
|
|
69
|
+
|
|
70
|
+
constructor(component: Component, props: NodeViewRendererProps, options?: Partial<Options>) {
|
|
71
|
+
super(component, props, options)
|
|
72
|
+
|
|
73
|
+
if (!this.node.isLeaf) {
|
|
74
|
+
if (this.options.contentDOMElementTag) {
|
|
75
|
+
this.contentDOMElement = document.createElement(this.options.contentDOMElementTag)
|
|
76
|
+
} else {
|
|
77
|
+
this.contentDOMElement = document.createElement(this.node.isInline ? 'span' : 'div')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.contentDOMElement.dataset.nodeViewContentReact = ''
|
|
81
|
+
this.contentDOMElement.dataset.nodeViewWrapper = ''
|
|
82
|
+
|
|
83
|
+
// For some reason the whiteSpace prop is not inherited properly in Chrome and Safari
|
|
84
|
+
// With this fix it seems to work fine
|
|
85
|
+
// See: https://github.com/ueberdosis/tiptap/issues/1197
|
|
86
|
+
this.contentDOMElement.style.whiteSpace = 'inherit'
|
|
87
|
+
|
|
88
|
+
const contentTarget = this.dom.querySelector('[data-node-view-content]')
|
|
89
|
+
|
|
90
|
+
if (!contentTarget) {
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
contentTarget.appendChild(this.contentDOMElement)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Setup the React component.
|
|
100
|
+
* Called on initialization.
|
|
101
|
+
*/
|
|
102
|
+
mount() {
|
|
103
|
+
const props = {
|
|
104
|
+
editor: this.editor,
|
|
105
|
+
node: this.node,
|
|
106
|
+
decorations: this.decorations as DecorationWithType[],
|
|
107
|
+
innerDecorations: this.innerDecorations,
|
|
108
|
+
view: this.view,
|
|
109
|
+
selected: false,
|
|
110
|
+
extension: this.extension,
|
|
111
|
+
HTMLAttributes: this.HTMLAttributes,
|
|
112
|
+
getPos: () => this.getPos(),
|
|
113
|
+
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
|
|
114
|
+
deleteNode: () => this.deleteNode(),
|
|
115
|
+
ref: createRef<T>(),
|
|
116
|
+
} satisfies ReactNodeViewProps<T>
|
|
117
|
+
|
|
118
|
+
if (!(this.component as any).displayName) {
|
|
119
|
+
const capitalizeFirstChar = (string: string): string => {
|
|
120
|
+
return string.charAt(0).toUpperCase() + string.substring(1)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.component.displayName = capitalizeFirstChar(this.extension.name)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const onDragStart = this.onDragStart.bind(this)
|
|
127
|
+
const nodeViewContentRef: ReactNodeViewContextProps['nodeViewContentRef'] = element => {
|
|
128
|
+
if (element && this.contentDOMElement && element.firstChild !== this.contentDOMElement) {
|
|
129
|
+
// remove the nodeViewWrapper attribute from the element
|
|
130
|
+
if (element.hasAttribute('data-node-view-wrapper')) {
|
|
131
|
+
element.removeAttribute('data-node-view-wrapper')
|
|
132
|
+
}
|
|
133
|
+
element.appendChild(this.contentDOMElement)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const context = { onDragStart, nodeViewContentRef }
|
|
137
|
+
const Component = this.component
|
|
138
|
+
// For performance reasons, we memoize the provider component
|
|
139
|
+
// And all of the things it requires are declared outside of the component, so it doesn't need to re-render
|
|
140
|
+
const ReactNodeViewProvider: NamedExoticComponent<ReactNodeViewProps<T>> = memo(componentProps => {
|
|
141
|
+
return (
|
|
142
|
+
<ReactNodeViewContext.Provider value={context}>
|
|
143
|
+
{createElement(Component, componentProps)}
|
|
144
|
+
</ReactNodeViewContext.Provider>
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
ReactNodeViewProvider.displayName = 'ReactNodeView'
|
|
149
|
+
|
|
150
|
+
let as = this.node.isInline ? 'span' : 'div'
|
|
151
|
+
|
|
152
|
+
if (this.options.as) {
|
|
153
|
+
as = this.options.as
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const { className = '' } = this.options
|
|
157
|
+
|
|
158
|
+
this.handleSelectionUpdate = this.handleSelectionUpdate.bind(this)
|
|
159
|
+
|
|
160
|
+
this.renderer = new ReactRenderer(ReactNodeViewProvider, {
|
|
161
|
+
editor: this.editor,
|
|
162
|
+
props,
|
|
163
|
+
as,
|
|
164
|
+
className: `node-${this.node.type.name} ${className}`.trim(),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
this.editor.on('selectionUpdate', this.handleSelectionUpdate)
|
|
168
|
+
this.updateElementAttributes()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Return the DOM element.
|
|
173
|
+
* This is the element that will be used to display the node view.
|
|
174
|
+
*/
|
|
175
|
+
get dom() {
|
|
176
|
+
if (
|
|
177
|
+
this.renderer.element.firstElementChild &&
|
|
178
|
+
!this.renderer.element.firstElementChild?.hasAttribute('data-node-view-wrapper')
|
|
179
|
+
) {
|
|
180
|
+
throw Error('Please use the NodeViewWrapper component for your node view.')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return this.renderer.element
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Return the content DOM element.
|
|
188
|
+
* This is the element that will be used to display the rich-text content of the node.
|
|
189
|
+
*/
|
|
190
|
+
get contentDOM() {
|
|
191
|
+
if (this.node.isLeaf) {
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return this.contentDOMElement
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* On editor selection update, check if the node is selected.
|
|
200
|
+
* If it is, call `selectNode`, otherwise call `deselectNode`.
|
|
201
|
+
*/
|
|
202
|
+
handleSelectionUpdate() {
|
|
203
|
+
const { from, to } = this.editor.state.selection
|
|
204
|
+
const pos = this.getPos()
|
|
205
|
+
|
|
206
|
+
if (typeof pos !== 'number') {
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (from <= pos && to >= pos + this.node.nodeSize) {
|
|
211
|
+
if (this.renderer.props.selected) {
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this.selectNode()
|
|
216
|
+
} else {
|
|
217
|
+
if (!this.renderer.props.selected) {
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.deselectNode()
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* On update, update the React component.
|
|
227
|
+
* To prevent unnecessary updates, the `update` option can be used.
|
|
228
|
+
*/
|
|
229
|
+
update(node: Node, decorations: readonly Decoration[], innerDecorations: DecorationSource): boolean {
|
|
230
|
+
const rerenderComponent = (props?: Record<string, any>) => {
|
|
231
|
+
this.renderer.updateProps(props)
|
|
232
|
+
if (typeof this.options.attrs === 'function') {
|
|
233
|
+
this.updateElementAttributes()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (node.type !== this.node.type) {
|
|
238
|
+
return false
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (typeof this.options.update === 'function') {
|
|
242
|
+
const oldNode = this.node
|
|
243
|
+
const oldDecorations = this.decorations
|
|
244
|
+
const oldInnerDecorations = this.innerDecorations
|
|
245
|
+
|
|
246
|
+
this.node = node
|
|
247
|
+
this.decorations = decorations
|
|
248
|
+
this.innerDecorations = innerDecorations
|
|
249
|
+
|
|
250
|
+
return this.options.update({
|
|
251
|
+
oldNode,
|
|
252
|
+
oldDecorations,
|
|
253
|
+
newNode: node,
|
|
254
|
+
newDecorations: decorations,
|
|
255
|
+
oldInnerDecorations,
|
|
256
|
+
innerDecorations,
|
|
257
|
+
updateProps: () => rerenderComponent({ node, decorations, innerDecorations }),
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (node === this.node && this.decorations === decorations && this.innerDecorations === innerDecorations) {
|
|
262
|
+
return true
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
this.node = node
|
|
266
|
+
this.decorations = decorations
|
|
267
|
+
this.innerDecorations = innerDecorations
|
|
268
|
+
|
|
269
|
+
rerenderComponent({ node, decorations, innerDecorations })
|
|
270
|
+
|
|
271
|
+
return true
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Select the node.
|
|
276
|
+
* Add the `selected` prop and the `ProseMirror-selectednode` class.
|
|
277
|
+
*/
|
|
278
|
+
selectNode() {
|
|
279
|
+
this.renderer.updateProps({
|
|
280
|
+
selected: true,
|
|
281
|
+
})
|
|
282
|
+
this.renderer.element.classList.add('ProseMirror-selectednode')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Deselect the node.
|
|
287
|
+
* Remove the `selected` prop and the `ProseMirror-selectednode` class.
|
|
288
|
+
*/
|
|
289
|
+
deselectNode() {
|
|
290
|
+
this.renderer.updateProps({
|
|
291
|
+
selected: false,
|
|
292
|
+
})
|
|
293
|
+
this.renderer.element.classList.remove('ProseMirror-selectednode')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Destroy the React component instance.
|
|
298
|
+
*/
|
|
299
|
+
destroy() {
|
|
300
|
+
this.renderer.destroy()
|
|
301
|
+
this.editor.off('selectionUpdate', this.handleSelectionUpdate)
|
|
302
|
+
this.contentDOMElement = null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Update the attributes of the top-level element that holds the React component.
|
|
307
|
+
* Applying the attributes defined in the `attrs` option.
|
|
308
|
+
*/
|
|
309
|
+
updateElementAttributes() {
|
|
310
|
+
if (this.options.attrs) {
|
|
311
|
+
let attrsObj: Record<string, string> = {}
|
|
312
|
+
|
|
313
|
+
if (typeof this.options.attrs === 'function') {
|
|
314
|
+
const extensionAttributes = this.editor.extensionManager.attributes
|
|
315
|
+
const HTMLAttributes = getRenderedAttributes(this.node, extensionAttributes)
|
|
316
|
+
|
|
317
|
+
attrsObj = this.options.attrs({ node: this.node, HTMLAttributes })
|
|
318
|
+
} else {
|
|
319
|
+
attrsObj = this.options.attrs
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this.renderer.updateAttributes(attrsObj)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Create a React node view renderer.
|
|
329
|
+
*/
|
|
330
|
+
export function ReactNodeViewRenderer<T = HTMLElement>(
|
|
331
|
+
component: ComponentType<ReactNodeViewProps<T>>,
|
|
332
|
+
options?: Partial<ReactNodeViewRendererOptions>,
|
|
333
|
+
): NodeViewRenderer {
|
|
334
|
+
return props => {
|
|
335
|
+
// try to get the parent component
|
|
336
|
+
// this is important for vue devtools to show the component hierarchy correctly
|
|
337
|
+
// maybe it’s `undefined` because <editor-content> isn’t rendered yet
|
|
338
|
+
if (!(props.editor as EditorWithContentComponent).contentComponent) {
|
|
339
|
+
return {} as unknown as ProseMirrorNodeView
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return new ReactNodeView<T>(component, props, options)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { Editor } from '@blockslides/core'
|
|
2
|
+
import type {
|
|
3
|
+
ComponentClass,
|
|
4
|
+
ForwardRefExoticComponent,
|
|
5
|
+
FunctionComponent,
|
|
6
|
+
PropsWithoutRef,
|
|
7
|
+
ReactNode,
|
|
8
|
+
RefAttributes,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import { version as reactVersion } from 'react'
|
|
11
|
+
import { flushSync } from 'react-dom'
|
|
12
|
+
|
|
13
|
+
import type { EditorWithContentComponent } from './Editor.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check if a component is a class component.
|
|
17
|
+
* @param Component
|
|
18
|
+
* @returns {boolean}
|
|
19
|
+
*/
|
|
20
|
+
function isClassComponent(Component: any) {
|
|
21
|
+
return !!(typeof Component === 'function' && Component.prototype && Component.prototype.isReactComponent)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if a component is a forward ref component.
|
|
26
|
+
* @param Component
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
function isForwardRefComponent(Component: any) {
|
|
30
|
+
return !!(
|
|
31
|
+
typeof Component === 'object' &&
|
|
32
|
+
Component.$$typeof &&
|
|
33
|
+
(Component.$$typeof.toString() === 'Symbol(react.forward_ref)' ||
|
|
34
|
+
Component.$$typeof.description === 'react.forward_ref')
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if a component is a memoized component.
|
|
40
|
+
* @param Component
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
function isMemoComponent(Component: any) {
|
|
44
|
+
return !!(
|
|
45
|
+
typeof Component === 'object' &&
|
|
46
|
+
Component.$$typeof &&
|
|
47
|
+
(Component.$$typeof.toString() === 'Symbol(react.memo)' || Component.$$typeof.description === 'react.memo')
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if a component can safely receive a ref prop.
|
|
53
|
+
* This includes class components, forwardRef components, and memoized components
|
|
54
|
+
* that wrap forwardRef or class components.
|
|
55
|
+
* @param Component
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
function canReceiveRef(Component: any) {
|
|
59
|
+
// Check if it's a class component
|
|
60
|
+
if (isClassComponent(Component)) {
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check if it's a forwardRef component
|
|
65
|
+
if (isForwardRefComponent(Component)) {
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check if it's a memoized component
|
|
70
|
+
if (isMemoComponent(Component)) {
|
|
71
|
+
// For memoized components, check the wrapped component
|
|
72
|
+
const wrappedComponent = Component.type
|
|
73
|
+
if (wrappedComponent) {
|
|
74
|
+
return isClassComponent(wrappedComponent) || isForwardRefComponent(wrappedComponent)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if we're running React 19+ by detecting if function components support ref props
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
function isReact19Plus(): boolean {
|
|
86
|
+
// React 19 is detected by checking React version if available
|
|
87
|
+
// In practice, we'll use a more conservative approach and assume React 18 behavior
|
|
88
|
+
// unless we can definitively detect React 19
|
|
89
|
+
try {
|
|
90
|
+
// @ts-ignore
|
|
91
|
+
if (reactVersion) {
|
|
92
|
+
const majorVersion = parseInt(reactVersion.split('.')[0], 10)
|
|
93
|
+
return majorVersion >= 19
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Fallback to React 18 behavior if we can't determine version
|
|
97
|
+
}
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ReactRendererOptions {
|
|
102
|
+
/**
|
|
103
|
+
* The editor instance.
|
|
104
|
+
* @type {Editor}
|
|
105
|
+
*/
|
|
106
|
+
editor: Editor
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The props for the component.
|
|
110
|
+
* @type {Record<string, any>}
|
|
111
|
+
* @default {}
|
|
112
|
+
*/
|
|
113
|
+
props?: Record<string, any>
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The tag name of the element.
|
|
117
|
+
* @type {string}
|
|
118
|
+
* @default 'div'
|
|
119
|
+
*/
|
|
120
|
+
as?: string
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The class name of the element.
|
|
124
|
+
* @type {string}
|
|
125
|
+
* @default ''
|
|
126
|
+
* @example 'foo bar'
|
|
127
|
+
*/
|
|
128
|
+
className?: string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type ComponentType<R, P> =
|
|
132
|
+
| ComponentClass<P>
|
|
133
|
+
| FunctionComponent<P>
|
|
134
|
+
| ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<R>>
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The ReactRenderer class. It's responsible for rendering React components inside the editor.
|
|
138
|
+
* @example
|
|
139
|
+
* new ReactRenderer(MyComponent, {
|
|
140
|
+
* editor,
|
|
141
|
+
* props: {
|
|
142
|
+
* foo: 'bar',
|
|
143
|
+
* },
|
|
144
|
+
* as: 'span',
|
|
145
|
+
* })
|
|
146
|
+
*/
|
|
147
|
+
export class ReactRenderer<R = unknown, P extends Record<string, any> = object> {
|
|
148
|
+
id: string
|
|
149
|
+
|
|
150
|
+
editor: Editor
|
|
151
|
+
|
|
152
|
+
component: any
|
|
153
|
+
|
|
154
|
+
element: HTMLElement
|
|
155
|
+
|
|
156
|
+
props: P
|
|
157
|
+
|
|
158
|
+
reactElement: ReactNode
|
|
159
|
+
|
|
160
|
+
ref: R | null = null
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Immediately creates element and renders the provided React component.
|
|
164
|
+
*/
|
|
165
|
+
constructor(
|
|
166
|
+
component: ComponentType<R, P>,
|
|
167
|
+
{ editor, props = {}, as = 'div', className = '' }: ReactRendererOptions,
|
|
168
|
+
) {
|
|
169
|
+
this.id = Math.floor(Math.random() * 0xffffffff).toString()
|
|
170
|
+
this.component = component
|
|
171
|
+
this.editor = editor as EditorWithContentComponent
|
|
172
|
+
this.props = props as P
|
|
173
|
+
this.element = document.createElement(as)
|
|
174
|
+
this.element.classList.add('react-renderer')
|
|
175
|
+
|
|
176
|
+
if (className) {
|
|
177
|
+
this.element.classList.add(...className.split(' '))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// If the editor is already initialized, we will need to
|
|
181
|
+
// synchronously render the component to ensure it renders
|
|
182
|
+
// together with Prosemirror's rendering.
|
|
183
|
+
if (this.editor.isInitialized) {
|
|
184
|
+
flushSync(() => {
|
|
185
|
+
this.render()
|
|
186
|
+
})
|
|
187
|
+
} else {
|
|
188
|
+
queueMicrotask(() => {
|
|
189
|
+
this.render()
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Render the React component.
|
|
196
|
+
*/
|
|
197
|
+
render(): void {
|
|
198
|
+
const Component = this.component
|
|
199
|
+
const props = this.props
|
|
200
|
+
const editor = this.editor as EditorWithContentComponent
|
|
201
|
+
|
|
202
|
+
// Handle ref forwarding with React 18/19 compatibility
|
|
203
|
+
const isReact19 = isReact19Plus()
|
|
204
|
+
const componentCanReceiveRef = canReceiveRef(Component)
|
|
205
|
+
|
|
206
|
+
const elementProps = { ...props }
|
|
207
|
+
|
|
208
|
+
// Always remove ref if the component cannot receive it (unless React 19+)
|
|
209
|
+
if (elementProps.ref && !(isReact19 || componentCanReceiveRef)) {
|
|
210
|
+
delete elementProps.ref
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Only assign our own ref if allowed
|
|
214
|
+
if (!elementProps.ref && (isReact19 || componentCanReceiveRef)) {
|
|
215
|
+
// @ts-ignore - Setting ref prop for compatible components
|
|
216
|
+
elementProps.ref = (ref: R) => {
|
|
217
|
+
this.ref = ref
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.reactElement = <Component {...elementProps} />
|
|
222
|
+
|
|
223
|
+
editor?.contentComponent?.setRenderer(this.id, this)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Re-renders the React component with new props.
|
|
228
|
+
*/
|
|
229
|
+
updateProps(props: Record<string, any> = {}): void {
|
|
230
|
+
this.props = {
|
|
231
|
+
...this.props,
|
|
232
|
+
...props,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.render()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Destroy the React component.
|
|
240
|
+
*/
|
|
241
|
+
destroy(): void {
|
|
242
|
+
const editor = this.editor as EditorWithContentComponent
|
|
243
|
+
|
|
244
|
+
editor?.contentComponent?.removeRenderer(this.id)
|
|
245
|
+
// If the consumer appended the element to the document (for example
|
|
246
|
+
// many demos append the renderer element to document.body), make sure
|
|
247
|
+
// we remove it here to avoid leaking DOM nodes / React roots.
|
|
248
|
+
try {
|
|
249
|
+
if (this.element && this.element.parentNode) {
|
|
250
|
+
this.element.parentNode.removeChild(this.element)
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// ignore DOM removal errors
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Update the attributes of the element that holds the React component.
|
|
259
|
+
*/
|
|
260
|
+
updateAttributes(attributes: Record<string, string>): void {
|
|
261
|
+
Object.keys(attributes).forEach(key => {
|
|
262
|
+
this.element.setAttribute(key, attributes[key])
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from "./Context.js";
|
|
2
|
+
export * from "./EditorContent.js";
|
|
3
|
+
export * from "./NodeViewContent.js";
|
|
4
|
+
export * from "./NodeViewWrapper.js";
|
|
5
|
+
export * from "./ReactMarkViewRenderer.js";
|
|
6
|
+
export * from "./ReactNodeViewRenderer.js";
|
|
7
|
+
export * from "./ReactRenderer.js";
|
|
8
|
+
export * from "./types.js";
|
|
9
|
+
export * from "./useEditor.js";
|
|
10
|
+
export * from "./useEditorState.js";
|
|
11
|
+
export * from "./useReactNodeView.js";
|
|
12
|
+
export * from "@blockslides/core";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type BubbleMenuPluginProps, BubbleMenuPlugin } from '@blockslides/extension-bubble-menu'
|
|
2
|
+
import { useCurrentEditor } from '@blockslides/react'
|
|
3
|
+
import React, { useEffect, useRef } from 'react'
|
|
4
|
+
import { createPortal } from 'react-dom'
|
|
5
|
+
|
|
6
|
+
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
|
|
7
|
+
|
|
8
|
+
export type BubbleMenuProps = Optional<Omit<Optional<BubbleMenuPluginProps, 'pluginKey'>, 'element'>, 'editor'> &
|
|
9
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
10
|
+
|
|
11
|
+
export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
|
|
12
|
+
(
|
|
13
|
+
{
|
|
14
|
+
pluginKey = 'bubbleMenu',
|
|
15
|
+
editor,
|
|
16
|
+
updateDelay,
|
|
17
|
+
resizeDelay,
|
|
18
|
+
appendTo,
|
|
19
|
+
shouldShow = null,
|
|
20
|
+
getReferencedVirtualElement,
|
|
21
|
+
options,
|
|
22
|
+
children,
|
|
23
|
+
...restProps
|
|
24
|
+
},
|
|
25
|
+
ref,
|
|
26
|
+
) => {
|
|
27
|
+
const menuEl = useRef(document.createElement('div'))
|
|
28
|
+
|
|
29
|
+
if (typeof ref === 'function') {
|
|
30
|
+
ref(menuEl.current)
|
|
31
|
+
} else if (ref) {
|
|
32
|
+
ref.current = menuEl.current
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { editor: currentEditor } = useCurrentEditor()
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const bubbleMenuElement = menuEl.current
|
|
39
|
+
bubbleMenuElement.style.visibility = 'hidden'
|
|
40
|
+
bubbleMenuElement.style.position = 'absolute'
|
|
41
|
+
|
|
42
|
+
if (editor?.isDestroyed || (currentEditor as any)?.isDestroyed) {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const attachToEditor = editor || currentEditor
|
|
47
|
+
|
|
48
|
+
if (!attachToEditor) {
|
|
49
|
+
console.warn('BubbleMenu component is not rendered inside of an editor component or does not have editor prop.')
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const plugin = BubbleMenuPlugin({
|
|
54
|
+
updateDelay,
|
|
55
|
+
resizeDelay,
|
|
56
|
+
editor: attachToEditor,
|
|
57
|
+
element: bubbleMenuElement,
|
|
58
|
+
appendTo,
|
|
59
|
+
pluginKey,
|
|
60
|
+
shouldShow,
|
|
61
|
+
getReferencedVirtualElement,
|
|
62
|
+
options,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
attachToEditor.registerPlugin(plugin)
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
attachToEditor.unregisterPlugin(pluginKey)
|
|
69
|
+
window.requestAnimationFrame(() => {
|
|
70
|
+
if (bubbleMenuElement.parentNode) {
|
|
71
|
+
bubbleMenuElement.parentNode.removeChild(bubbleMenuElement)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}, [
|
|
76
|
+
editor,
|
|
77
|
+
currentEditor,
|
|
78
|
+
pluginKey,
|
|
79
|
+
updateDelay,
|
|
80
|
+
resizeDelay,
|
|
81
|
+
appendTo,
|
|
82
|
+
shouldShow,
|
|
83
|
+
getReferencedVirtualElement,
|
|
84
|
+
options,
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
return createPortal(<div {...restProps}>{children}</div>, menuEl.current)
|
|
88
|
+
},
|
|
89
|
+
)
|