Skip to content
This repository has been archived by the owner on Nov 7, 2022. It is now read-only.

Commit

Permalink
Refactor react node view (#463)
Browse files Browse the repository at this point in the history
refactor(editor): adds react node view to replacing Tiptap node view
  • Loading branch information
Darmody authored Jul 26, 2022
1 parent 81947a3 commit 63fb1b5
Show file tree
Hide file tree
Showing 47 changed files with 855 additions and 346 deletions.
7 changes: 7 additions & 0 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,12 @@
"lint:type": "run -T tsc",
"test:jest": "run -T jest",
"test:jest:fix": "run -T jest --silent -u"
},
"dependencies": {
"@mashcard/active-support": "workspace:^",
"prosemirror-model": "^1.18.1",
"prosemirror-state": "^1.4.1",
"prosemirror-transform": "^1.6.0",
"prosemirror-view": "^1.27.0"
}
}
25 changes: 25 additions & 0 deletions packages/editor/src/ReactNodeVew/NodeViewContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createContext, FC, PropsWithChildren, RefObject, useContext, useEffect, useMemo, useRef } from 'react'

export interface NodeViewContextProps {
setContentDOM?: (contentDOM: HTMLElement | null) => void
}

export const NodeViewContext = createContext<NodeViewContextProps>({})

export function useNodeContent<T extends HTMLElement>(): RefObject<T> {
const { setContentDOM } = useContext(NodeViewContext)
const ref = useRef<T>(null)

useEffect(() => {
setContentDOM?.(ref.current)
}, [setContentDOM])

return ref
}

export interface NodeViewContainerProps extends PropsWithChildren, NodeViewContextProps {}

export const NodeViewContainer: FC<NodeViewContainerProps> = ({ setContentDOM, children }) => {
const contextValue = useMemo<NodeViewContextProps>(() => ({ setContentDOM }), [setContentDOM])
return <NodeViewContext.Provider value={contextValue}>{children}</NodeViewContext.Provider>
}
116 changes: 116 additions & 0 deletions packages/editor/src/ReactNodeVew/ReactNodeView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { uuid } from '@mashcard/active-support'
import { EditorView, NodeView } from 'prosemirror-view'
import { ComponentType } from 'react'
import { NodePortal } from '../NodePortal'
import { flushSync } from 'react-dom'
import { NodeViewContainer } from './NodeViewContainer'

export { useNodeContent } from './NodeViewContainer'
export { NodeViewContainer }

/**
* The store for nodePortals mutation
*/
export interface NodePortalStore {
/**
* Adds or update node portal.
*/
set: (nodePortal: NodePortal) => void
/**
* Removes a node portal by id.
*/
remove: (id: string) => void
}

/**
* A Prosemirror NodeView rendering view by react component.
* @param component The react component for NodeView rendering.
* @param editorView The editor view of current Prosemirror editor state
* @param nodePortalStore The store for nodePortals mutation
* @param asContainer Define the tag of react portal container, optional argument.
*/
export class ReactNodeView<ComponentProps = {}> implements NodeView {
/**
* Unique id
*/
public readonly id: string
/**
* The store for nodePortals mutation
*/
public readonly nodePortalStore: NodePortalStore
/**
* The react component for NodeView rendering.
*/
public readonly component: ComponentType<ComponentProps>
/**
* The dom container for NodeView
*/
public readonly container: HTMLElement
/**
* The editor view of current Prosemirror editor state
*/
public readonly editorView: EditorView

public contentDOM: HTMLElement | null
public dom: Element

constructor({
component,
componentProps,
editorView,
nodePortalStore,
asContainer
}: {
component: ComponentType<ComponentProps>
componentProps: ComponentProps
editorView: EditorView
nodePortalStore: NodePortalStore
asContainer?: keyof HTMLElementTagNameMap
}) {
this.editorView = editorView
this.container = document.createElement(asContainer ?? 'div')
this.contentDOM = document.createElement('div')

this.nodePortalStore = nodePortalStore
this.id = uuid()
this.component = component

// renders react component synchronously when NodeView created.
flushSync(() => {
this.renderComponent(componentProps)
})

this.dom = this.container
}

public destroy(): void {
this.nodePortalStore.remove(this.id)
this.contentDOM = null
}

/**
* Used to set current contentDOM. Set it via useRef in react component for example.
* @param contentDOM The DOM node that should hold the node's content.
*/
protected readonly setContentDOM = (contentDOM: HTMLElement | null): void => {
if (contentDOM) {
contentDOM.append(...Array.from(this.contentDOM?.childNodes ?? []))
}
this.contentDOM = contentDOM
}

/**
* Renders the component for updating NodeView's view.
*/
protected renderComponent(props: ComponentProps): void {
this.nodePortalStore.set({
id: this.id,
container: this.container,
child: (
<NodeViewContainer setContentDOM={this.setContentDOM}>
<this.component {...props} />
</NodeViewContainer>
)
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { render } from '@testing-library/react'
import { FC } from 'react'
import { NodeViewContainer, useNodeContent } from '../NodeViewContainer'

describe('NodeViewContainer', () => {
it('sets contentDOM correctly', () => {
const setContentDOM = jest.fn()

const TestComponent: FC = () => {
const ref = useNodeContent<HTMLDivElement>()

return <div ref={ref} />
}

render(
<NodeViewContainer setContentDOM={setContentDOM}>
<TestComponent />
</NodeViewContainer>
)

expect(setContentDOM).toBeCalled()
})
})
86 changes: 86 additions & 0 deletions packages/editor/src/ReactNodeVew/__tests__/ReactNodeView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { render, screen } from '@testing-library/react'
import { FC, ReactElement, ReactNode } from 'react'
import { NodePortalStore, ReactNodeView, useNodeContent } from '../ReactNodeView'

describe('ReactNodeView', () => {
it('renders NodeView component correctly', () => {
const componentProps = {
text: 'text'
}
const component: FC<typeof componentProps> = props => <div>{props.text}</div>

let child: ReactNode

const nodePortalStore: NodePortalStore = {
set(portal) {
child = portal.child
},
remove: jest.fn()
}

// eslint-disable-next-line no-new
new ReactNodeView({
component,
componentProps,
editorView: {} as any,
nodePortalStore
})

render(child as ReactElement)

expect(screen.getByText(componentProps.text)).toBeInTheDocument()
})

it('renders NodeView component with NodeContent correctly', () => {
const Component: FC = () => {
const ref = useNodeContent<HTMLDivElement>()
return <div ref={ref} />
}

const text = 'text'

let child: ReactNode

const nodePortalStore: NodePortalStore = {
set(portal) {
child = portal.child
},
remove: jest.fn()
}

const nodeView = new ReactNodeView({
component: Component,
componentProps: {},
editorView: {} as any,
nodePortalStore
})

const textElement = document.createElement('span')
textElement.innerHTML = text
nodeView.contentDOM?.append(textElement)

render(child as ReactElement)

expect(screen.getByText(text)).toBeInTheDocument()
})

it('destroys NodeView correctly', () => {
const component: FC = () => <div />

const nodePortalStore: NodePortalStore = {
set: jest.fn(),
remove: jest.fn()
}

const nodeView = new ReactNodeView({
component,
componentProps: {},
editorView: {} as any,
nodePortalStore
})

nodeView.destroy()

expect(nodePortalStore.remove).toBeCalled()
})
})
1 change: 1 addition & 0 deletions packages/editor/src/ReactNodeVew/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ReactNodeView'
1 change: 1 addition & 0 deletions packages/editor/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './prosemirror'
export * from './NodePortal'
export * from './ReactNodeVew'
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,8 @@ exports[`BlockquoteView renders blockquote correctly 1`] = `
style="white-space: normal; pointer-events: unset;"
>
<blockquote
as="blockquote"
class="mc-c-fGUTRT"
data-node-view-content=""
data-placeholder=""
style="white-space: pre-wrap;"
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,8 @@ exports[`CalloutView renders CalloutView with emoji icon correctly 1`] = `
</button>
</div>
<span
as="span"
class="mc-c-hbPmqD"
data-node-view-content=""
data-placeholder=""
style="white-space: pre-wrap;"
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
exports[`CodeBlockView matches snapshot correctly 1`] = `
<div>
<div
class="mc-c-bxRahZ mc-c-eNvXfk"
class="mc-c-gSKecW mc-c-kMAtnc"
data-node-view-wrapper=""
style="white-space: normal; pointer-events: unset;"
>
Expand Down Expand Up @@ -70,14 +70,11 @@ exports[`CodeBlockView matches snapshot correctly 1`] = `
</div>
<pre
class="line-numbers language-javascript"
data-placeholder=""
spellcheck="false"
>
<code
as="code"
class="undefined language-javascript"
data-node-view-content=""
data-placeholder=""
style="white-space: pre-wrap;"
/>
</pre>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,8 @@ exports[`HeadingView matches snapshot correctly 1`] = `
style="white-space: normal; position: relative; pointer-events: unset;"
>
<h1
as="h1"
class="mc-c-fHQyyx mc-c-fHQyyx-cOVgnz-level-1"
data-node-view-content=""
data-placeholder="placeholder.heading1"
style="white-space: pre-wrap;"
/>
</div>
</div>
Expand All @@ -24,11 +21,8 @@ exports[`HeadingView renders correspond heading according to level level 2 1`] =
style="white-space: normal; position: relative; pointer-events: unset;"
>
<h2
as="h2"
class="mc-c-fHQyyx mc-c-fHQyyx-gPLAez-level-2"
data-node-view-content=""
data-placeholder="placeholder.heading2"
style="white-space: pre-wrap;"
/>
</div>
</div>
Expand All @@ -41,11 +35,8 @@ exports[`HeadingView renders correspond heading according to level level 3 1`] =
style="white-space: normal; position: relative; pointer-events: unset;"
>
<h3
as="h3"
class="mc-c-fHQyyx mc-c-fHQyyx-llzMLw-level-3"
data-node-view-content=""
data-placeholder="placeholder.heading3"
style="white-space: pre-wrap;"
/>
</div>
</div>
Expand All @@ -58,11 +49,8 @@ exports[`HeadingView renders correspond heading according to level level 4 1`] =
style="white-space: normal; position: relative; pointer-events: unset;"
>
<h4
as="h4"
class="mc-c-fHQyyx mc-c-fHQyyx-dWBeGD-level-4"
data-node-view-content=""
data-placeholder="placeholder.heading4"
style="white-space: pre-wrap;"
/>
</div>
</div>
Expand All @@ -75,11 +63,8 @@ exports[`HeadingView renders correspond heading according to level level 5 1`] =
style="white-space: normal; position: relative; pointer-events: unset;"
>
<h5
as="h5"
class="mc-c-fHQyyx mc-c-fHQyyx-hCSGfy-level-5"
data-node-view-content=""
data-placeholder="placeholder.heading5"
style="white-space: pre-wrap;"
/>
</div>
</div>
Expand Down
Loading

0 comments on commit 63fb1b5

Please sign in to comment.