mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-12 12:43:48 +08:00
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:
parent
062afaf327
commit
704f4620b3
66
.changeset/red-ants-wonder.md
Normal file
66
.changeset/red-ants-wonder.md
Normal 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
|
||||||
|
```
|
@ -12,8 +12,8 @@ context('/src/Extensions/CollaborationWithMenus/React/', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should have menu plugins initiated', () => {
|
it('should have menu plugins initiated', () => {
|
||||||
|
cy.wait(700)
|
||||||
cy.get('.tiptap').then(async ([{ editor }]) => {
|
cy.get('.tiptap').then(async ([{ editor }]) => {
|
||||||
await cy.wait(100)
|
|
||||||
const bubbleMenuPlugin = editor.view.state.plugins.find(plugin => plugin.spec.key?.key === 'bubbleMenu$')
|
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 floatingMenuPlugin = editor.view.state.plugins.find(plugin => plugin.spec.key?.key === 'floatingMenu$')
|
||||||
const hasBothMenuPluginsLoaded = !!bubbleMenuPlugin && !!floatingMenuPlugin
|
const hasBothMenuPluginsLoaded = !!bubbleMenuPlugin && !!floatingMenuPlugin
|
||||||
|
@ -57,10 +57,12 @@ export class Editor extends EventEmitter<EditorEvents> {
|
|||||||
|
|
||||||
public schema!: Schema
|
public schema!: Schema
|
||||||
|
|
||||||
public view!: EditorView
|
private editorView: EditorView | null = null
|
||||||
|
|
||||||
public isFocused = false
|
public isFocused = false
|
||||||
|
|
||||||
|
private editorState!: EditorState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The editor is considered initialized after the `create` event has been emitted.
|
* 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 instanceId = Math.random().toString(36).slice(2, 9)
|
||||||
|
|
||||||
public options: EditorOptions = {
|
public options: EditorOptions = {
|
||||||
element: document.createElement('div'),
|
element: typeof document !== 'undefined' ? document.createElement('div') : null,
|
||||||
content: '',
|
content: '',
|
||||||
injectCSS: true,
|
injectCSS: true,
|
||||||
injectNonce: undefined,
|
injectNonce: undefined,
|
||||||
@ -113,8 +115,6 @@ export class Editor extends EventEmitter<EditorEvents> {
|
|||||||
this.on('beforeCreate', this.options.onBeforeCreate)
|
this.on('beforeCreate', this.options.onBeforeCreate)
|
||||||
this.emit('beforeCreate', { editor: this })
|
this.emit('beforeCreate', { editor: this })
|
||||||
this.on('contentError', this.options.onContentError)
|
this.on('contentError', this.options.onContentError)
|
||||||
this.createView()
|
|
||||||
this.injectCSS()
|
|
||||||
this.on('create', this.options.onCreate)
|
this.on('create', this.options.onCreate)
|
||||||
this.on('update', this.options.onUpdate)
|
this.on('update', this.options.onUpdate)
|
||||||
this.on('selectionUpdate', this.options.onSelectionUpdate)
|
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('paste', ({ event, slice }) => this.options.onPaste(event, slice))
|
||||||
this.on('delete', this.options.onDelete)
|
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(() => {
|
window.setTimeout(() => {
|
||||||
if (this.isDestroyed) {
|
if (this.isDestroyed) {
|
||||||
return
|
return
|
||||||
@ -169,7 +192,7 @@ export class Editor extends EventEmitter<EditorEvents> {
|
|||||||
* Inject CSS styles.
|
* Inject CSS styles.
|
||||||
*/
|
*/
|
||||||
private injectCSS(): void {
|
private injectCSS(): void {
|
||||||
if (this.options.injectCSS && document) {
|
if (this.options.injectCSS && typeof document !== 'undefined') {
|
||||||
this.css = createStyleTag(style, this.options.injectNonce)
|
this.css = createStyleTag(style, this.options.injectNonce)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -185,7 +208,7 @@ export class Editor extends EventEmitter<EditorEvents> {
|
|||||||
...options,
|
...options,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.view || !this.state || this.isDestroyed) {
|
if (!this.editorView || !this.state || this.isDestroyed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,11 +240,57 @@ export class Editor extends EventEmitter<EditorEvents> {
|
|||||||
return this.options.editable && this.view && this.view.editable
|
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.
|
* Returns the editor state.
|
||||||
*/
|
*/
|
||||||
public get state(): EditorState {
|
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
|
let doc: ProseMirrorNode
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -371,9 +440,14 @@ export class Editor extends EventEmitter<EditorEvents> {
|
|||||||
errorOnInvalidContent: false,
|
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,
|
...this.options.editorProps,
|
||||||
attributes: {
|
attributes: {
|
||||||
// add `role="textbox"` to the editor element
|
// add `role="textbox"` to the editor element
|
||||||
@ -381,10 +455,7 @@ export class Editor extends EventEmitter<EditorEvents> {
|
|||||||
...this.options.editorProps?.attributes,
|
...this.options.editorProps?.attributes,
|
||||||
},
|
},
|
||||||
dispatchTransaction: this.dispatchTransaction.bind(this),
|
dispatchTransaction: this.dispatchTransaction.bind(this),
|
||||||
state: EditorState.create({
|
state: this.editorState,
|
||||||
doc,
|
|
||||||
selection: selection || undefined,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// `editor.view` is not yet available at this time.
|
// `editor.view` is not yet available at this time.
|
||||||
@ -397,6 +468,7 @@ export class Editor extends EventEmitter<EditorEvents> {
|
|||||||
|
|
||||||
this.createNodeViews()
|
this.createNodeViews()
|
||||||
this.prependClass()
|
this.prependClass()
|
||||||
|
this.injectCSS()
|
||||||
|
|
||||||
// Let’s store the editor instance in the DOM element.
|
// Let’s store the editor instance in the DOM element.
|
||||||
// So we’ll have access to it for tests.
|
// So we’ll have access to it for tests.
|
||||||
@ -607,15 +679,15 @@ export class Editor extends EventEmitter<EditorEvents> {
|
|||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
this.emit('destroy')
|
this.emit('destroy')
|
||||||
|
|
||||||
if (this.view) {
|
if (this.editorView) {
|
||||||
// Cleanup our reference to prevent circular references which caused memory leaks
|
// Cleanup our reference to prevent circular references which caused memory leaks
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const dom = this.view.dom as TiptapEditorHTMLElement
|
const dom = this.editorView.dom as TiptapEditorHTMLElement
|
||||||
|
|
||||||
if (dom && dom.editor) {
|
if (dom && dom.editor) {
|
||||||
delete dom.editor
|
delete dom.editor
|
||||||
}
|
}
|
||||||
this.view.destroy()
|
this.editorView.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.removeAllListeners()
|
this.removeAllListeners()
|
||||||
|
@ -225,8 +225,10 @@ export type EnableRules = (AnyExtension | string)[] | boolean
|
|||||||
export interface EditorOptions {
|
export interface EditorOptions {
|
||||||
/**
|
/**
|
||||||
* The element or selector to bind the editor to
|
* 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)
|
* The content of the editor (HTML, JSON, or a JSON array)
|
||||||
*/
|
*/
|
||||||
|
@ -15,6 +15,9 @@ const removeWhitespaces = (node: HTMLElement) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function elementFromString(value: string): 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
|
// add a wrapper to preserve leading and trailing whitespace
|
||||||
const wrappedValue = `<body>${value}</body>`
|
const wrappedValue = `<body>${value}</body>`
|
||||||
|
|
||||||
|
@ -175,10 +175,11 @@ export class PureEditorContent extends React.Component<
|
|||||||
|
|
||||||
editor.contentComponent = null
|
editor.contentComponent = null
|
||||||
|
|
||||||
if (!editor.options.element.firstChild) {
|
if (!editor.options.element?.firstChild) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO using the new editor.mount method might allow us to remove this
|
||||||
const newElement = document.createElement('div')
|
const newElement = document.createElement('div')
|
||||||
|
|
||||||
newElement.append(...editor.options.element.childNodes)
|
newElement.append(...editor.options.element.childNodes)
|
||||||
|
@ -24,7 +24,7 @@ export const EditorContent: Component = {
|
|||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
const element = this.$el
|
const element = this.$el
|
||||||
|
|
||||||
if (!element || !editor.options.element.firstChild) {
|
if (!element || !editor.options.element?.firstChild) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,10 +61,11 @@ export const EditorContent: Component = {
|
|||||||
|
|
||||||
editor.contentComponent = null
|
editor.contentComponent = null
|
||||||
|
|
||||||
if (!editor.options.element.firstChild) {
|
if (!editor.options.element?.firstChild) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO using the new editor.mount method might allow us to remove this
|
||||||
const newElement = document.createElement('div')
|
const newElement = document.createElement('div')
|
||||||
|
|
||||||
newElement.append(...editor.options.element.childNodes)
|
newElement.append(...editor.options.element.childNodes)
|
||||||
|
@ -32,10 +32,11 @@ export const EditorContent = defineComponent({
|
|||||||
|
|
||||||
if (editor && editor.options.element && rootEl.value) {
|
if (editor && editor.options.element && rootEl.value) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (!rootEl.value || !editor.options.element.firstChild) {
|
if (!rootEl.value || !editor.options.element?.firstChild) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO using the new editor.mount method might allow us to remove this
|
||||||
const element = unref(rootEl.value)
|
const element = unref(rootEl.value)
|
||||||
|
|
||||||
rootEl.value.append(...editor.options.element.childNodes)
|
rootEl.value.append(...editor.options.element.childNodes)
|
||||||
|
Loading…
Reference in New Issue
Block a user