Skip to content

Commit

Permalink
Fix cursor not following to new node when using a react node view (#3331
Browse files Browse the repository at this point in the history
)

* Refactor: extract `setRenderer` and `removeRenderer` methods

* Refactor: avoid using a mutable ES6 Map in React component state

The React docs recommend treating the `state` as immutable. See e.g.:
https://reactjs.org/docs/react-component.html#state

* Fix: flush EditorContent state changes immediately once initialized

This fixes the cursor problem described in tiptap#3200

Node views need to be rendered immediately when they're created so that
the editor can correctly position the cursor. That's achieved using
`flushSync` whenever a new node view renderer is added.

However, `flushSync` cannot be used from inside a React component
lifecycle method.

By keeping an instance variable to determine if initialization has
happened, we can avoid using `flushSync` from inside the `componentDidMount`
and `componentDidUpdate` methods, and still call it whenever a new node view
is created afterwards.
  • Loading branch information
ruipserra committed Nov 25, 2022
1 parent 3d3f6f7 commit 369f109
Show file tree
Hide file tree
Showing 3 changed files with 54 additions and 32 deletions.
8 changes: 7 additions & 1 deletion packages/react/src/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { Editor as CoreEditor } from '@tiptap/core'
import React from 'react'

import { EditorContentProps, EditorContentState } from './EditorContent'
import { ReactRenderer } from './ReactRenderer'

type ContentComponent = React.Component<EditorContentProps, EditorContentState> & {
setRenderer(id: string, renderer: ReactRenderer): void;
removeRenderer(id: string): void;
}

export class Editor extends CoreEditor {
public contentComponent: React.Component<EditorContentProps, EditorContentState> | null = null
public contentComponent: ContentComponent | null = null
}
50 changes: 45 additions & 5 deletions packages/react/src/EditorContent.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import React, { HTMLProps } from 'react'
import ReactDOM from 'react-dom'
import ReactDOM, { flushSync } from 'react-dom'

import { Editor } from './Editor'
import { ReactRenderer } from './ReactRenderer'

const Portals: React.FC<{ renderers: Map<string, ReactRenderer> }> = ({ renderers }) => {
const Portals: React.FC<{ renderers: Record<string, ReactRenderer> }> = ({ renderers }) => {
return (
<>
{Array.from(renderers).map(([key, renderer]) => {
{Object.entries(renderers).map(([key, renderer]) => {
return ReactDOM.createPortal(
renderer.reactElement,
renderer.element,
Expand All @@ -23,18 +23,21 @@ export interface EditorContentProps extends HTMLProps<HTMLDivElement> {
}

export interface EditorContentState {
renderers: Map<string, ReactRenderer>
renderers: Record<string, ReactRenderer>
}

export class PureEditorContent extends React.Component<EditorContentProps, EditorContentState> {
editorContentRef: React.RefObject<any>

initialized: boolean

constructor(props: EditorContentProps) {
super(props)
this.editorContentRef = React.createRef()
this.initialized = false

this.state = {
renderers: new Map(),
renderers: {},
}
}

Expand Down Expand Up @@ -65,9 +68,46 @@ export class PureEditorContent extends React.Component<EditorContentProps, Edito
editor.contentComponent = this

editor.createNodeViews()

this.initialized = true
}
}

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

Expand Down
28 changes: 2 additions & 26 deletions packages/react/src/ReactRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Editor } from '@tiptap/core'
import React from 'react'
import { flushSync } from 'react-dom'

import { Editor as ExtendedEditor } from './Editor'

Expand Down Expand Up @@ -78,18 +77,7 @@ export class ReactRenderer<R = unknown, P = unknown> {

this.reactElement = <Component {...props } />

queueMicrotask(() => {
flushSync(() => {
if (this.editor?.contentComponent) {
this.editor.contentComponent.setState({
renderers: this.editor.contentComponent.state.renderers.set(
this.id,
this,
),
})
}
})
})
this.editor?.contentComponent?.setRenderer(this.id, this)
}

updateProps(props: Record<string, any> = {}): void {
Expand All @@ -102,18 +90,6 @@ export class ReactRenderer<R = unknown, P = unknown> {
}

destroy(): void {
queueMicrotask(() => {
flushSync(() => {
if (this.editor?.contentComponent) {
const { renderers } = this.editor.contentComponent.state

renderers.delete(this.id)

this.editor.contentComponent.setState({
renderers,
})
}
})
})
this.editor?.contentComponent?.removeRenderer(this.id)
}
}

0 comments on commit 369f109

Please sign in to comment.