feat(core): add ability to run editor without a browser + mount API (#6050)

When editor options is provided `element: null`, then the editor view will not be created, allowing use of the editor & it's commands without a browser in pure JS.
This commit is contained in:
Nick Perez 2025-01-24 13:53:33 +01:00 committed by GitHub
parent 062afaf327
commit 704f4620b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 170 additions and 24 deletions

View File

@ -0,0 +1,66 @@
---
'@tiptap/core': minor
---
This introduces a new behavior for the editor, the ability to be safely run on the server-side (without rendering).
`prosemirror-view` encapsulates all view (& DOM) related code, and cannot safely be SSR'd, but, the majority of the editor instance itself is in plain JS that does not require DOM APIs (unless your content is specified in HTML).
But, we have so many convenient methods available for manipulating content. So, it is a shame that they could not be used on the server side too. With this change, the editor can be rendered on the server-side and will use a stub for select prosemirror-view methods. If accessing unsupported methods or values on the `editor.view`, you will encounter runtime errors, so it is important for you to test to see if the methods you call actually work.
This is a step towards being able to server-side render content, but, it is not completely supported yet. This does not mean that you can render an editor instance on the server and expect it to just output any HTML.
## Usage
If you pass `element: null` to your editor options:
- the `editor.view` will not be initialized
- the editor will not emit it's `'create'` event
- the focus will not be initialized to it's first position
You can however, later use the new `mount` function on the instance, which will mount the editor view to a DOM element. This obviously will not be allowed on the server which has no document object.
Therefore, this will work on the server:
```ts
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
const editor = new Editor({
element: null,
content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello, World!' }] }] },
extensions: [StarterKit],
})
editor
.chain()
.selectAll()
.setContent({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'XYZ' }] }] })
.run()
console.log(editor.state.doc.toJSON())
// { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'XYZ' } ] } ] }
```
Any of these things will not work on the server, and result in a runtime error:
```ts
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
const editor = new Editor({
// document will not be defined in a server environment
element: document.createElement('div'),
content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello, World!' }] }] },
extensions: [StarterKit],
})
editor
.chain()
// focus is a command which depends on the editor-view, so it will not work in a server environment
.focus()
.run()
console.log(editor.getHTML())
// getHTML relies on the editor-view, so it will not work in a server environment
```

View File

@ -12,8 +12,8 @@ context('/src/Extensions/CollaborationWithMenus/React/', () => {
})
it('should have menu plugins initiated', () => {
cy.wait(700)
cy.get('.tiptap').then(async ([{ editor }]) => {
await cy.wait(100)
const bubbleMenuPlugin = editor.view.state.plugins.find(plugin => plugin.spec.key?.key === 'bubbleMenu$')
const floatingMenuPlugin = editor.view.state.plugins.find(plugin => plugin.spec.key?.key === 'floatingMenu$')
const hasBothMenuPluginsLoaded = !!bubbleMenuPlugin && !!floatingMenuPlugin

View File

@ -57,10 +57,12 @@ export class Editor extends EventEmitter<EditorEvents> {
public schema!: Schema
public view!: EditorView
private editorView: EditorView | null = null
public isFocused = false
private editorState!: EditorState
/**
* The editor is considered initialized after the `create` event has been emitted.
*/
@ -74,7 +76,7 @@ export class Editor extends EventEmitter<EditorEvents> {
public instanceId = Math.random().toString(36).slice(2, 9)
public options: EditorOptions = {
element: document.createElement('div'),
element: typeof document !== 'undefined' ? document.createElement('div') : null,
content: '',
injectCSS: true,
injectNonce: undefined,
@ -113,8 +115,6 @@ export class Editor extends EventEmitter<EditorEvents> {
this.on('beforeCreate', this.options.onBeforeCreate)
this.emit('beforeCreate', { editor: this })
this.on('contentError', this.options.onContentError)
this.createView()
this.injectCSS()
this.on('create', this.options.onCreate)
this.on('update', this.options.onUpdate)
this.on('selectionUpdate', this.options.onSelectionUpdate)
@ -126,6 +126,29 @@ export class Editor extends EventEmitter<EditorEvents> {
this.on('paste', ({ event, slice }) => this.options.onPaste(event, slice))
this.on('delete', this.options.onDelete)
const initialDoc = this.createDoc()
const selection = resolveFocusPosition(initialDoc, this.options.autofocus)
// Set editor state immediately, so that it's available independently from the view
this.editorState = EditorState.create({
doc: initialDoc,
schema: this.schema,
selection: selection || undefined,
})
if (this.options.element) {
this.mount(this.options.element)
}
}
public mount(el: NonNullable<EditorOptions['element']> & {}) {
if (typeof document === 'undefined') {
throw new Error(
`[tiptap error]: The editor cannot be mounted because there is no 'document' defined in this environment.`,
)
}
this.createView(el)
window.setTimeout(() => {
if (this.isDestroyed) {
return
@ -169,7 +192,7 @@ export class Editor extends EventEmitter<EditorEvents> {
* Inject CSS styles.
*/
private injectCSS(): void {
if (this.options.injectCSS && document) {
if (this.options.injectCSS && typeof document !== 'undefined') {
this.css = createStyleTag(style, this.options.injectNonce)
}
}
@ -185,7 +208,7 @@ export class Editor extends EventEmitter<EditorEvents> {
...options,
}
if (!this.view || !this.state || this.isDestroyed) {
if (!this.editorView || !this.state || this.isDestroyed) {
return
}
@ -217,11 +240,57 @@ export class Editor extends EventEmitter<EditorEvents> {
return this.options.editable && this.view && this.view.editable
}
/**
* Returns the editor state.
*/
public get view(): EditorView {
if (this.editorView) {
return this.editorView
}
return new Proxy(
{
state: this.editorState,
updateState: (state: EditorState): ReturnType<EditorView['updateState']> => {
this.editorState = state
},
dispatch: (tr: Transaction): ReturnType<EditorView['dispatch']> => {
this.editorState = this.state.apply(tr)
},
// Stub some commonly accessed properties to prevent errors
composing: false,
dragging: null,
editable: true,
} as EditorView,
{
get: (obj, key) => {
// Specifically always return the most recent editorState
if (key === 'state') {
return this.editorState
}
if (key in obj) {
return Reflect.get(obj, key)
}
// We throw an error here, because we know the view is not available
throw new Error(
`[tiptap error]: The editor view is not available. Cannot access view['${key as string}']. The editor may not be mounted yet.`,
)
},
},
) as EditorView
}
/**
* Returns the editor state.
*/
public get state(): EditorState {
return this.view.state
if (this.editorView) {
this.editorState = this.view.state
}
return this.editorState
}
/**
@ -334,9 +403,9 @@ export class Editor extends EventEmitter<EditorEvents> {
}
/**
* Creates a ProseMirror view.
* Creates the initial document.
*/
private createView(): void {
private createDoc(): ProseMirrorNode {
let doc: ProseMirrorNode
try {
@ -371,9 +440,14 @@ export class Editor extends EventEmitter<EditorEvents> {
errorOnInvalidContent: false,
})
}
const selection = resolveFocusPosition(doc, this.options.autofocus)
return doc
}
this.view = new EditorView(this.options.element, {
/**
* Creates a ProseMirror view.
*/
private createView(element: NonNullable<EditorOptions['element']> & {}): void {
this.editorView = new EditorView(element, {
...this.options.editorProps,
attributes: {
// add `role="textbox"` to the editor element
@ -381,10 +455,7 @@ export class Editor extends EventEmitter<EditorEvents> {
...this.options.editorProps?.attributes,
},
dispatchTransaction: this.dispatchTransaction.bind(this),
state: EditorState.create({
doc,
selection: selection || undefined,
}),
state: this.editorState,
})
// `editor.view` is not yet available at this time.
@ -397,6 +468,7 @@ export class Editor extends EventEmitter<EditorEvents> {
this.createNodeViews()
this.prependClass()
this.injectCSS()
// Lets store the editor instance in the DOM element.
// So well have access to it for tests.
@ -607,15 +679,15 @@ export class Editor extends EventEmitter<EditorEvents> {
public destroy(): void {
this.emit('destroy')
if (this.view) {
if (this.editorView) {
// Cleanup our reference to prevent circular references which caused memory leaks
// @ts-ignore
const dom = this.view.dom as TiptapEditorHTMLElement
const dom = this.editorView.dom as TiptapEditorHTMLElement
if (dom && dom.editor) {
delete dom.editor
}
this.view.destroy()
this.editorView.destroy()
}
this.removeAllListeners()

View File

@ -225,8 +225,10 @@ export type EnableRules = (AnyExtension | string)[] | boolean
export interface EditorOptions {
/**
* The element or selector to bind the editor to
* If `null` is passed, the editor will not be mounted automatically
* If a function is passed, it will be called with the editor's root element
*/
element: Element
element: Element | null
/**
* The content of the editor (HTML, JSON, or a JSON array)
*/

View File

@ -15,6 +15,9 @@ const removeWhitespaces = (node: HTMLElement) => {
}
export function elementFromString(value: string): HTMLElement {
if (typeof window === 'undefined') {
throw new Error('[tiptap error]: there is no window object available, so this function cannot be used')
}
// add a wrapper to preserve leading and trailing whitespace
const wrappedValue = `<body>${value}</body>`

View File

@ -175,10 +175,11 @@ export class PureEditorContent extends React.Component<
editor.contentComponent = null
if (!editor.options.element.firstChild) {
if (!editor.options.element?.firstChild) {
return
}
// TODO using the new editor.mount method might allow us to remove this
const newElement = document.createElement('div')
newElement.append(...editor.options.element.childNodes)

View File

@ -24,7 +24,7 @@ export const EditorContent: Component = {
this.$nextTick(() => {
const element = this.$el
if (!element || !editor.options.element.firstChild) {
if (!element || !editor.options.element?.firstChild) {
return
}
@ -61,10 +61,11 @@ export const EditorContent: Component = {
editor.contentComponent = null
if (!editor.options.element.firstChild) {
if (!editor.options.element?.firstChild) {
return
}
// TODO using the new editor.mount method might allow us to remove this
const newElement = document.createElement('div')
newElement.append(...editor.options.element.childNodes)

View File

@ -32,10 +32,11 @@ export const EditorContent = defineComponent({
if (editor && editor.options.element && rootEl.value) {
nextTick(() => {
if (!rootEl.value || !editor.options.element.firstChild) {
if (!rootEl.value || !editor.options.element?.firstChild) {
return
}
// TODO using the new editor.mount method might allow us to remove this
const element = unref(rootEl.value)
rootEl.value.append(...editor.options.element.childNodes)