mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-07 17:43:49 +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', () => {
|
||||
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
|
||||
|
@ -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()
|
||||
|
||||
// Let’s store the editor instance in the DOM element.
|
||||
// So we’ll 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()
|
||||
|
@ -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)
|
||||
*/
|
||||
|
@ -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>`
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user