@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.
@@ -0,0 +1,60 @@
1
+ import type { Editor } from '@blockslides/core'
2
+ import type { HTMLAttributes, ReactNode } from 'react'
3
+ import React, { createContext, useContext, useMemo } from 'react'
4
+
5
+ import { EditorContent } from './EditorContent.js'
6
+ import type { UseEditorOptions } from './useEditor.js'
7
+ import { useEditor } from './useEditor.js'
8
+
9
+ export type EditorContextValue = {
10
+ editor: Editor | null
11
+ }
12
+
13
+ export const EditorContext = createContext<EditorContextValue>({
14
+ editor: null,
15
+ })
16
+
17
+ export const EditorConsumer = EditorContext.Consumer
18
+
19
+ /**
20
+ * A hook to get the current editor instance.
21
+ */
22
+ export const useCurrentEditor = () => useContext(EditorContext)
23
+
24
+ export type EditorProviderProps = {
25
+ children?: ReactNode
26
+ slotBefore?: ReactNode
27
+ slotAfter?: ReactNode
28
+ editorContainerProps?: HTMLAttributes<HTMLDivElement>
29
+ } & UseEditorOptions
30
+
31
+ /**
32
+ * This is the provider component for the editor.
33
+ * It allows the editor to be accessible across the entire component tree
34
+ * with `useCurrentEditor`.
35
+ */
36
+ export function EditorProvider({
37
+ children,
38
+ slotAfter,
39
+ slotBefore,
40
+ editorContainerProps = {},
41
+ ...editorOptions
42
+ }: EditorProviderProps) {
43
+ const editor = useEditor(editorOptions)
44
+ const contextValue = useMemo(() => ({ editor }), [editor])
45
+
46
+ if (!editor) {
47
+ return null
48
+ }
49
+
50
+ return (
51
+ <EditorContext.Provider value={contextValue}>
52
+ {slotBefore}
53
+ <EditorConsumer>
54
+ {({ editor: currentEditor }) => <EditorContent editor={currentEditor} {...editorContainerProps} />}
55
+ </EditorConsumer>
56
+ {children}
57
+ {slotAfter}
58
+ </EditorContext.Provider>
59
+ )
60
+ }
package/src/Editor.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { Editor } from "@blockslides/core";
2
+ import type { ReactPortal } from "react";
3
+
4
+ import type { ReactRenderer } from "./ReactRenderer.js";
5
+
6
+ export type EditorWithContentComponent = Editor & {
7
+ contentComponent?: ContentComponent | null;
8
+ };
9
+ export type ContentComponent = {
10
+ setRenderer(id: string, renderer: ReactRenderer): void;
11
+ removeRenderer(id: string): void;
12
+ subscribe: (callback: () => void) => () => void;
13
+ getSnapshot: () => Record<string, ReactPortal>;
14
+ getServerSnapshot: () => Record<string, ReactPortal>;
15
+ };
@@ -0,0 +1,229 @@
1
+ import type { Editor } from '@blockslides/core'
2
+ import type { ForwardedRef, HTMLProps, LegacyRef, MutableRefObject } from 'react'
3
+ import React, { forwardRef } from 'react'
4
+ import ReactDOM from 'react-dom'
5
+ import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js'
6
+
7
+ import type { ContentComponent, EditorWithContentComponent } from './Editor.js'
8
+ import type { ReactRenderer } from './ReactRenderer.js'
9
+
10
+ const mergeRefs = <T extends HTMLDivElement>(...refs: Array<MutableRefObject<T> | LegacyRef<T> | undefined>) => {
11
+ return (node: T) => {
12
+ refs.forEach(ref => {
13
+ if (typeof ref === 'function') {
14
+ ref(node)
15
+ } else if (ref) {
16
+ ; (ref as MutableRefObject<T | null>).current = node
17
+ }
18
+ })
19
+ }
20
+ }
21
+
22
+ /**
23
+ * This component renders all of the editor's node views.
24
+ */
25
+ const Portals: React.FC<{ contentComponent: ContentComponent }> = ({ contentComponent }) => {
26
+ // For performance reasons, we render the node view portals on state changes only
27
+ const renderers = useSyncExternalStore(
28
+ contentComponent.subscribe,
29
+ contentComponent.getSnapshot,
30
+ contentComponent.getServerSnapshot,
31
+ )
32
+
33
+ // This allows us to directly render the portals without any additional wrapper
34
+ return <>{Object.values(renderers)}</>
35
+ }
36
+
37
+ export interface EditorContentProps extends HTMLProps<HTMLDivElement> {
38
+ editor: Editor | null
39
+ innerRef?: ForwardedRef<HTMLDivElement | null>
40
+ }
41
+
42
+ function getInstance(): ContentComponent {
43
+ const subscribers = new Set<() => void>()
44
+ let renderers: Record<string, React.ReactPortal> = {}
45
+
46
+ return {
47
+ /**
48
+ * Subscribe to the editor instance's changes.
49
+ */
50
+ subscribe(callback: () => void) {
51
+ subscribers.add(callback)
52
+ return () => {
53
+ subscribers.delete(callback)
54
+ }
55
+ },
56
+ getSnapshot() {
57
+ return renderers
58
+ },
59
+ getServerSnapshot() {
60
+ return renderers
61
+ },
62
+ /**
63
+ * Adds a new NodeView Renderer to the editor.
64
+ */
65
+ setRenderer(id: string, renderer: ReactRenderer) {
66
+ renderers = {
67
+ ...renderers,
68
+ [id]: ReactDOM.createPortal(renderer.reactElement, renderer.element, id),
69
+ }
70
+
71
+ subscribers.forEach(subscriber => subscriber())
72
+ },
73
+ /**
74
+ * Removes a NodeView Renderer from the editor.
75
+ */
76
+ removeRenderer(id: string) {
77
+ const nextRenderers = { ...renderers }
78
+
79
+ delete nextRenderers[id]
80
+ renderers = nextRenderers
81
+ subscribers.forEach(subscriber => subscriber())
82
+ },
83
+ }
84
+ }
85
+
86
+ export class PureEditorContent extends React.Component<
87
+ EditorContentProps,
88
+ { hasContentComponentInitialized: boolean }
89
+ > {
90
+ editorContentRef: React.RefObject<any>
91
+
92
+ initialized: boolean
93
+
94
+ unsubscribeToContentComponent?: () => void
95
+
96
+ constructor(props: EditorContentProps) {
97
+ super(props)
98
+ this.editorContentRef = React.createRef()
99
+ this.initialized = false
100
+
101
+ this.state = {
102
+ hasContentComponentInitialized: Boolean((props.editor as EditorWithContentComponent | null)?.contentComponent),
103
+ }
104
+ }
105
+
106
+ componentDidMount() {
107
+ this.init()
108
+ }
109
+
110
+ componentDidUpdate() {
111
+ this.init()
112
+ }
113
+
114
+ init() {
115
+ const editor = this.props.editor as EditorWithContentComponent | null
116
+
117
+ if (editor && !editor.isDestroyed && editor.options.element) {
118
+ if (editor.contentComponent) {
119
+ return
120
+ }
121
+
122
+ const element = this.editorContentRef.current
123
+
124
+ element.append(editor.view.dom)
125
+
126
+ editor.setOptions({
127
+ element,
128
+ })
129
+
130
+ editor.contentComponent = getInstance()
131
+
132
+ // Has the content component been initialized?
133
+ if (!this.state.hasContentComponentInitialized) {
134
+ // Subscribe to the content component
135
+ this.unsubscribeToContentComponent = editor.contentComponent.subscribe(() => {
136
+ this.setState(prevState => {
137
+ if (!prevState.hasContentComponentInitialized) {
138
+ return {
139
+ hasContentComponentInitialized: true,
140
+ }
141
+ }
142
+ return prevState
143
+ })
144
+
145
+ // Unsubscribe to previous content component
146
+ if (this.unsubscribeToContentComponent) {
147
+ this.unsubscribeToContentComponent()
148
+ }
149
+ })
150
+ }
151
+
152
+ editor.createNodeViews()
153
+
154
+ this.initialized = true
155
+ }
156
+ }
157
+
158
+ componentWillUnmount() {
159
+ const editor = this.props.editor as EditorWithContentComponent | null
160
+
161
+ if (!editor) {
162
+ return
163
+ }
164
+
165
+ this.initialized = false
166
+
167
+ if (!editor.isDestroyed) {
168
+ editor.view.setProps({
169
+ nodeViews: {},
170
+ })
171
+ }
172
+
173
+ if (this.unsubscribeToContentComponent) {
174
+ this.unsubscribeToContentComponent()
175
+ }
176
+
177
+ editor.contentComponent = null
178
+
179
+ // try to reset the editor element
180
+ // may fail if this editor's view.dom was never initialized/mounted yet
181
+ try {
182
+ if (!editor.view.dom?.firstChild) {
183
+ return
184
+ }
185
+
186
+ // TODO using the new editor.mount method might allow us to remove this
187
+ const newElement = document.createElement('div')
188
+
189
+ newElement.append(editor.view.dom)
190
+
191
+ editor.setOptions({
192
+ element: newElement,
193
+ })
194
+ } catch {
195
+ // do nothing, nothing to reset
196
+ }
197
+ }
198
+
199
+ render() {
200
+ const { editor, innerRef, ...rest } = this.props
201
+
202
+ return (
203
+ <>
204
+ <div ref={mergeRefs(innerRef, this.editorContentRef)} {...rest} />
205
+ {/* @ts-ignore */}
206
+ {editor?.contentComponent && <Portals contentComponent={editor.contentComponent} />}
207
+ </>
208
+ )
209
+ }
210
+ }
211
+
212
+ // EditorContent should be re-created whenever the Editor instance changes
213
+ const EditorContentWithKey = forwardRef<HTMLDivElement, EditorContentProps>(
214
+ (props: Omit<EditorContentProps, 'innerRef'>, ref) => {
215
+ const key = React.useMemo(() => {
216
+ return Math.floor(Math.random() * 0xffffffff).toString()
217
+ // eslint-disable-next-line react-hooks/exhaustive-deps
218
+ }, [props.editor])
219
+
220
+ // Can't use JSX here because it conflicts with the type definition of Vue's JSX, so use createElement
221
+ return React.createElement(PureEditorContent, {
222
+ key,
223
+ innerRef: ref,
224
+ ...props,
225
+ })
226
+ },
227
+ )
228
+
229
+ export const EditorContent = React.memo(EditorContentWithKey)
@@ -0,0 +1,30 @@
1
+ import type { ComponentProps } from 'react'
2
+ import React from 'react'
3
+
4
+ import { useReactNodeView } from './useReactNodeView.js'
5
+
6
+ export type NodeViewContentProps<T extends keyof React.JSX.IntrinsicElements = 'div'> = {
7
+ as?: NoInfer<T>
8
+ } & ComponentProps<T>
9
+
10
+ export function NodeViewContent<T extends keyof React.JSX.IntrinsicElements = 'div'>({
11
+ as: Tag = 'div' as T,
12
+ ...props
13
+ }: NodeViewContentProps<T>) {
14
+ const { nodeViewContentRef, nodeViewContentChildren } = useReactNodeView()
15
+
16
+ return (
17
+ // @ts-ignore
18
+ <Tag
19
+ {...props}
20
+ ref={nodeViewContentRef}
21
+ data-node-view-content=""
22
+ style={{
23
+ whiteSpace: 'pre-wrap',
24
+ ...props.style,
25
+ }}
26
+ >
27
+ {nodeViewContentChildren}
28
+ </Tag>
29
+ )
30
+ }
@@ -0,0 +1,27 @@
1
+ import React from 'react'
2
+
3
+ import { useReactNodeView } from './useReactNodeView.js'
4
+
5
+ export interface NodeViewWrapperProps {
6
+ [key: string]: any
7
+ as?: React.ElementType
8
+ }
9
+
10
+ export const NodeViewWrapper: React.FC<NodeViewWrapperProps> = React.forwardRef((props, ref) => {
11
+ const { onDragStart } = useReactNodeView()
12
+ const Tag = props.as || 'div'
13
+
14
+ return (
15
+ // @ts-ignore
16
+ <Tag
17
+ {...props}
18
+ ref={ref}
19
+ data-node-view-wrapper=""
20
+ onDragStart={onDragStart}
21
+ style={{
22
+ whiteSpace: 'normal',
23
+ ...props.style,
24
+ }}
25
+ />
26
+ )
27
+ })
@@ -0,0 +1,106 @@
1
+ /* eslint-disable @typescript-eslint/no-shadow */
2
+ import type { MarkViewProps, MarkViewRenderer, MarkViewRendererOptions } from '@blockslides/core'
3
+ import { MarkView } from '@blockslides/core'
4
+ import React from 'react'
5
+
6
+ // import { flushSync } from 'react-dom'
7
+ import { ReactRenderer } from './ReactRenderer.js'
8
+
9
+ export interface MarkViewContextProps {
10
+ markViewContentRef: (element: HTMLElement | null) => void
11
+ }
12
+ export const ReactMarkViewContext = React.createContext<MarkViewContextProps>({
13
+ markViewContentRef: () => {
14
+ // do nothing
15
+ },
16
+ })
17
+
18
+ export type MarkViewContentProps<T extends keyof React.JSX.IntrinsicElements = 'span'> = {
19
+ as?: T
20
+ } & Omit<React.ComponentProps<T>, 'as'>
21
+
22
+ export const MarkViewContent = <T extends keyof React.JSX.IntrinsicElements = 'span'>(
23
+ props: MarkViewContentProps<T>,
24
+ ) => {
25
+ const { as: Tag = 'span', ...rest } = props
26
+ const { markViewContentRef } = React.useContext(ReactMarkViewContext)
27
+
28
+ return (
29
+ // @ts-ignore
30
+ <Tag {...rest} ref={markViewContentRef} data-mark-view-content="" />
31
+ )
32
+ }
33
+
34
+ export interface ReactMarkViewRendererOptions extends MarkViewRendererOptions {
35
+ /**
36
+ * The tag name of the element wrapping the React component.
37
+ */
38
+ as?: string
39
+ className?: string
40
+ attrs?: { [key: string]: string }
41
+ }
42
+
43
+ export class ReactMarkView extends MarkView<React.ComponentType<MarkViewProps>, ReactMarkViewRendererOptions> {
44
+ renderer: ReactRenderer
45
+ contentDOMElement: HTMLElement
46
+
47
+ constructor(
48
+ component: React.ComponentType<MarkViewProps>,
49
+ props: MarkViewProps,
50
+ options?: Partial<ReactMarkViewRendererOptions>,
51
+ ) {
52
+ super(component, props, options)
53
+
54
+ const { as = 'span', attrs, className = '' } = options || {}
55
+ const componentProps = { ...props, updateAttributes: this.updateAttributes.bind(this) } satisfies MarkViewProps
56
+
57
+ this.contentDOMElement = document.createElement('span')
58
+
59
+ const markViewContentRef: MarkViewContextProps['markViewContentRef'] = el => {
60
+ if (el && !el.contains(this.contentDOMElement)) {
61
+ el.appendChild(this.contentDOMElement)
62
+ }
63
+ }
64
+ const context: MarkViewContextProps = {
65
+ markViewContentRef,
66
+ }
67
+
68
+ // For performance reasons, we memoize the provider component
69
+ // And all of the things it requires are declared outside of the component, so it doesn't need to re-render
70
+ const ReactMarkViewProvider: React.FunctionComponent<MarkViewProps> = React.memo(componentProps => {
71
+ return (
72
+ <ReactMarkViewContext.Provider value={context}>
73
+ {React.createElement(component, componentProps)}
74
+ </ReactMarkViewContext.Provider>
75
+ )
76
+ })
77
+
78
+ ReactMarkViewProvider.displayName = 'ReactMarkView'
79
+
80
+ this.renderer = new ReactRenderer(ReactMarkViewProvider, {
81
+ editor: props.editor,
82
+ props: componentProps,
83
+ as,
84
+ className: `mark-${props.mark.type.name} ${className}`.trim(),
85
+ })
86
+
87
+ if (attrs) {
88
+ this.renderer.updateAttributes(attrs)
89
+ }
90
+ }
91
+
92
+ get dom() {
93
+ return this.renderer.element
94
+ }
95
+
96
+ get contentDOM() {
97
+ return this.contentDOMElement
98
+ }
99
+ }
100
+
101
+ export function ReactMarkViewRenderer(
102
+ component: React.ComponentType<MarkViewProps>,
103
+ options: Partial<ReactMarkViewRendererOptions> = {},
104
+ ): MarkViewRenderer {
105
+ return props => new ReactMarkView(component, props, options)
106
+ }