@tiptap/core
Version:
headless rich text editor
775 lines (661 loc) • 21.7 kB
text/typescript
/* eslint-disable @typescript-eslint/no-empty-object-type */
import type { MarkType, Node as ProseMirrorNode, NodeType, Schema } from '@tiptap/pm/model'
import type { Plugin, PluginKey, Transaction } from '@tiptap/pm/state'
import { EditorState } from '@tiptap/pm/state'
import { EditorView } from '@tiptap/pm/view'
import { CommandManager } from './CommandManager.js'
import { EventEmitter } from './EventEmitter.js'
import { ExtensionManager } from './ExtensionManager.js'
import {
ClipboardTextSerializer,
Commands,
Delete,
Drop,
Editable,
FocusEvents,
Keymap,
Paste,
Tabindex,
} from './extensions/index.js'
import { createDocument } from './helpers/createDocument.js'
import { getAttributes } from './helpers/getAttributes.js'
import { getHTMLFromFragment } from './helpers/getHTMLFromFragment.js'
import { getText } from './helpers/getText.js'
import { getTextSerializersFromSchema } from './helpers/getTextSerializersFromSchema.js'
import { isActive } from './helpers/isActive.js'
import { isNodeEmpty } from './helpers/isNodeEmpty.js'
import { resolveFocusPosition } from './helpers/resolveFocusPosition.js'
import type { Storage } from './index.js'
import { NodePos } from './NodePos.js'
import { style } from './style.js'
import type {
CanCommands,
ChainedCommands,
DocumentType,
EditorEvents,
EditorOptions,
NodeType as TNodeType,
SingleCommands,
TextSerializer,
TextType as TTextType,
} from './types.js'
import { createStyleTag } from './utilities/createStyleTag.js'
import { isFunction } from './utilities/isFunction.js'
export * as extensions from './extensions/index.js'
// @ts-ignore
export interface TiptapEditorHTMLElement extends HTMLElement {
editor?: Editor
}
export class Editor extends EventEmitter<EditorEvents> {
private commandManager!: CommandManager
public extensionManager!: ExtensionManager
private css: HTMLStyleElement | null = null
private className = 'tiptap'
public schema!: Schema
private editorView: EditorView | null = null
public isFocused = false
private editorState!: EditorState
/**
* The editor is considered initialized after the `create` event has been emitted.
*/
public isInitialized = false
public extensionStorage: Storage = {} as Storage
/**
* A unique ID for this editor instance.
*/
public instanceId = Math.random().toString(36).slice(2, 9)
public options: EditorOptions = {
element: typeof document !== 'undefined' ? document.createElement('div') : null,
content: '',
injectCSS: true,
injectNonce: undefined,
extensions: [],
autofocus: false,
editable: true,
editorProps: {},
parseOptions: {},
coreExtensionOptions: {},
enableInputRules: true,
enablePasteRules: true,
enableCoreExtensions: true,
enableContentCheck: false,
emitContentError: false,
onBeforeCreate: () => null,
onCreate: () => null,
onMount: () => null,
onUnmount: () => null,
onUpdate: () => null,
onSelectionUpdate: () => null,
onTransaction: () => null,
onFocus: () => null,
onBlur: () => null,
onDestroy: () => null,
onContentError: ({ error }) => {
throw error
},
onPaste: () => null,
onDrop: () => null,
onDelete: () => null,
}
constructor(options: Partial<EditorOptions> = {}) {
super()
this.setOptions(options)
this.createExtensionManager()
this.createCommandManager()
this.createSchema()
this.on('beforeCreate', this.options.onBeforeCreate)
this.emit('beforeCreate', { editor: this })
this.on('mount', this.options.onMount)
this.on('unmount', this.options.onUnmount)
this.on('contentError', this.options.onContentError)
this.on('create', this.options.onCreate)
this.on('update', this.options.onUpdate)
this.on('selectionUpdate', this.options.onSelectionUpdate)
this.on('transaction', this.options.onTransaction)
this.on('focus', this.options.onFocus)
this.on('blur', this.options.onBlur)
this.on('destroy', this.options.onDestroy)
this.on('drop', ({ event, slice, moved }) => this.options.onDrop(event, slice, moved))
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)
}
}
/**
* Attach the editor to the DOM, creating a new editor view.
*/
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)
this.emit('mount', { editor: this })
if (this.css && !document.head.contains(this.css)) {
document.head.appendChild(this.css)
}
window.setTimeout(() => {
if (this.isDestroyed) {
return
}
this.commands.focus(this.options.autofocus)
this.emit('create', { editor: this })
this.isInitialized = true
}, 0)
}
/**
* Remove the editor from the DOM, but still allow remounting at a different point in time
*/
public unmount() {
if (this.editorView) {
// Cleanup our reference to prevent circular references which caused memory leaks
// @ts-ignore
const dom = this.editorView.dom as TiptapEditorHTMLElement
if (dom?.editor) {
delete dom.editor
}
this.editorView.destroy()
}
this.editorView = null
this.isInitialized = false
// Safely remove CSS element with fallback for test environments
// Only remove CSS if no other editors exist in the document after unmount
if (this.css && !document.querySelectorAll(`.${this.className}`).length) {
try {
if (typeof this.css.remove === 'function') {
this.css.remove()
} else if (this.css.parentNode) {
this.css.parentNode.removeChild(this.css)
}
} catch (error) {
// Silently handle any unexpected DOM removal errors in test environments
console.warn('Failed to remove CSS element:', error)
}
}
this.css = null
this.emit('unmount', { editor: this })
}
/**
* Returns the editor storage.
*/
public get storage(): Storage {
return this.extensionStorage
}
/**
* An object of all registered commands.
*/
public get commands(): SingleCommands {
return this.commandManager.commands
}
/**
* Create a command chain to call multiple commands at once.
*/
public chain(): ChainedCommands {
return this.commandManager.chain()
}
/**
* Check if a command or a command chain can be executed. Without executing it.
*/
public can(): CanCommands {
return this.commandManager.can()
}
/**
* Inject CSS styles.
*/
private injectCSS(): void {
if (this.options.injectCSS && typeof document !== 'undefined') {
this.css = createStyleTag(style, this.options.injectNonce)
}
}
/**
* Update editor options.
*
* @param options A list of options
*/
public setOptions(options: Partial<EditorOptions> = {}): void {
this.options = {
...this.options,
...options,
}
if (!this.editorView || !this.state || this.isDestroyed) {
return
}
if (this.options.editorProps) {
this.view.setProps(this.options.editorProps)
}
this.view.updateState(this.state)
}
/**
* Update editable state of the editor.
*/
public setEditable(editable: boolean, emitUpdate = true): void {
this.setOptions({ editable })
if (emitUpdate) {
this.emit('update', { editor: this, transaction: this.state.tr, appendedTransactions: [] })
}
}
/**
* Returns whether the editor is editable.
*/
public get isEditable(): boolean {
// since plugins are applied after creating the view
// `editable` is always `true` for one tick.
// that’s why we also have to check for `options.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.dispatchTransaction(tr)
},
// Stub some commonly accessed properties to prevent errors
composing: false,
dragging: null,
editable: true,
isDestroyed: false,
} as EditorView,
{
get: (obj, key) => {
if (this.editorView) {
// If the editor view is available, but the caller has a stale reference to the proxy,
// Just return what the editor view has.
return this.editorView[key as keyof EditorView]
}
// 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 {
if (this.editorView) {
this.editorState = this.view.state
}
return this.editorState
}
/**
* Register a ProseMirror plugin.
*
* @param plugin A ProseMirror plugin
* @param handlePlugins Control how to merge the plugin into the existing plugins.
* @returns The new editor state
*/
public registerPlugin(
plugin: Plugin,
handlePlugins?: (newPlugin: Plugin, plugins: Plugin[]) => Plugin[],
): EditorState {
const plugins = isFunction(handlePlugins)
? handlePlugins(plugin, [...this.state.plugins])
: [...this.state.plugins, plugin]
const state = this.state.reconfigure({ plugins })
this.view.updateState(state)
return state
}
/**
* Unregister a ProseMirror plugin.
*
* @param nameOrPluginKeyToRemove The plugins name
* @returns The new editor state or undefined if the editor is destroyed
*/
public unregisterPlugin(
nameOrPluginKeyToRemove: string | PluginKey | (string | PluginKey)[],
): EditorState | undefined {
if (this.isDestroyed) {
return undefined
}
const prevPlugins = this.state.plugins
let plugins = prevPlugins
;([] as (string | PluginKey)[]).concat(nameOrPluginKeyToRemove).forEach(nameOrPluginKey => {
// @ts-ignore
const name = typeof nameOrPluginKey === 'string' ? `${nameOrPluginKey}$` : nameOrPluginKey.key
// @ts-ignore
plugins = plugins.filter(plugin => !plugin.key.startsWith(name))
})
if (prevPlugins.length === plugins.length) {
// No plugin was removed, so we don’t need to update the state
return undefined
}
const state = this.state.reconfigure({
plugins,
})
this.view.updateState(state)
return state
}
/**
* Creates an extension manager.
*/
private createExtensionManager(): void {
const coreExtensions = this.options.enableCoreExtensions
? [
Editable,
ClipboardTextSerializer.configure({
blockSeparator: this.options.coreExtensionOptions?.clipboardTextSerializer?.blockSeparator,
}),
Commands,
FocusEvents,
Keymap,
Tabindex,
Drop,
Paste,
Delete,
].filter(ext => {
if (typeof this.options.enableCoreExtensions === 'object') {
return (
this.options.enableCoreExtensions[ext.name as keyof typeof this.options.enableCoreExtensions] !== false
)
}
return true
})
: []
const allExtensions = [...coreExtensions, ...this.options.extensions].filter(extension => {
return ['extension', 'node', 'mark'].includes(extension?.type)
})
this.extensionManager = new ExtensionManager(allExtensions, this)
}
/**
* Creates an command manager.
*/
private createCommandManager(): void {
this.commandManager = new CommandManager({
editor: this,
})
}
/**
* Creates a ProseMirror schema.
*/
private createSchema(): void {
this.schema = this.extensionManager.schema
}
/**
* Creates the initial document.
*/
private createDoc(): ProseMirrorNode {
let doc: ProseMirrorNode
try {
doc = createDocument(this.options.content, this.schema, this.options.parseOptions, {
errorOnInvalidContent: this.options.enableContentCheck,
})
} catch (e) {
if (
!(e instanceof Error) ||
!['[tiptap error]: Invalid JSON content', '[tiptap error]: Invalid HTML content'].includes(e.message)
) {
// Not the content error we were expecting
throw e
}
this.emit('contentError', {
editor: this,
error: e as Error,
disableCollaboration: () => {
if (
'collaboration' in this.storage &&
typeof this.storage.collaboration === 'object' &&
this.storage.collaboration
) {
;(this.storage.collaboration as any).isDisabled = true
}
// To avoid syncing back invalid content, reinitialize the extensions without the collaboration extension
this.options.extensions = this.options.extensions.filter(extension => extension.name !== 'collaboration')
// Restart the initialization process by recreating the extension manager with the new set of extensions
this.createExtensionManager()
},
})
// Content is invalid, but attempt to create it anyway, stripping out the invalid parts
doc = createDocument(this.options.content, this.schema, this.options.parseOptions, {
errorOnInvalidContent: false,
})
}
return doc
}
/**
* 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
role: 'textbox',
...this.options.editorProps?.attributes,
},
dispatchTransaction: this.dispatchTransaction.bind(this),
state: this.editorState,
markViews: this.extensionManager.markViews,
nodeViews: this.extensionManager.nodeViews,
})
// `editor.view` is not yet available at this time.
// Therefore we will add all plugins and node views directly afterwards.
const newState = this.state.reconfigure({
plugins: this.extensionManager.plugins,
})
this.view.updateState(newState)
this.prependClass()
this.injectCSS()
// Let’s store the editor instance in the DOM element.
// So we’ll have access to it for tests.
// @ts-ignore
const dom = this.view.dom as TiptapEditorHTMLElement
dom.editor = this
}
/**
* Creates all node and mark views.
*/
public createNodeViews(): void {
if (this.view.isDestroyed) {
return
}
this.view.setProps({
markViews: this.extensionManager.markViews,
nodeViews: this.extensionManager.nodeViews,
})
}
/**
* Prepend class name to element.
*/
public prependClass(): void {
this.view.dom.className = `${this.className} ${this.view.dom.className}`
}
public isCapturingTransaction = false
private capturedTransaction: Transaction | null = null
public captureTransaction(fn: () => void) {
this.isCapturingTransaction = true
fn()
this.isCapturingTransaction = false
const tr = this.capturedTransaction
this.capturedTransaction = null
return tr
}
/**
* The callback over which to send transactions (state updates) produced by the view.
*
* @param transaction An editor state transaction
*/
private dispatchTransaction(transaction: Transaction): void {
// if the editor / the view of the editor was destroyed
// the transaction should not be dispatched as there is no view anymore.
if (this.view.isDestroyed) {
return
}
if (this.isCapturingTransaction) {
if (!this.capturedTransaction) {
this.capturedTransaction = transaction
return
}
transaction.steps.forEach(step => this.capturedTransaction?.step(step))
return
}
// Apply transaction and get resulting state and transactions
const { state, transactions } = this.state.applyTransaction(transaction)
const selectionHasChanged = !this.state.selection.eq(state.selection)
const rootTrWasApplied = transactions.includes(transaction)
const prevState = this.state
this.emit('beforeTransaction', {
editor: this,
transaction,
nextState: state,
})
// If transaction was filtered out, we can return early
if (!rootTrWasApplied) {
return
}
this.view.updateState(state)
// Emit transaction event with appended transactions info
this.emit('transaction', {
editor: this,
transaction,
appendedTransactions: transactions.slice(1),
})
if (selectionHasChanged) {
this.emit('selectionUpdate', {
editor: this,
transaction,
})
}
// Only emit the latest between focus and blur events
const mostRecentFocusTr = transactions.findLast(tr => tr.getMeta('focus') || tr.getMeta('blur'))
const focus = mostRecentFocusTr?.getMeta('focus')
const blur = mostRecentFocusTr?.getMeta('blur')
if (focus) {
this.emit('focus', {
editor: this,
event: focus.event,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
transaction: mostRecentFocusTr!,
})
}
if (blur) {
this.emit('blur', {
editor: this,
event: blur.event,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
transaction: mostRecentFocusTr!,
})
}
// Compare states for update event
if (
transaction.getMeta('preventUpdate') ||
!transactions.some(tr => tr.docChanged) ||
prevState.doc.eq(state.doc)
) {
return
}
this.emit('update', {
editor: this,
transaction,
appendedTransactions: transactions.slice(1),
})
}
/**
* Get attributes of the currently selected node or mark.
*/
public getAttributes(nameOrType: string | NodeType | MarkType): Record<string, any> {
return getAttributes(this.state, nameOrType)
}
/**
* Returns if the currently selected node or mark is active.
*
* @param name Name of the node or mark
* @param attributes Attributes of the node or mark
*/
public isActive(name: string, attributes?: {}): boolean
public isActive(attributes: {}): boolean
public isActive(nameOrAttributes: string, attributesOrUndefined?: {}): boolean {
const name = typeof nameOrAttributes === 'string' ? nameOrAttributes : null
const attributes = typeof nameOrAttributes === 'string' ? attributesOrUndefined : nameOrAttributes
return isActive(this.state, name, attributes)
}
/**
* Get the document as JSON.
*/
public getJSON(): DocumentType<
Record<string, any> | undefined,
TNodeType<string, undefined | Record<string, any>, any, (TNodeType | TTextType)[]>[]
> {
return this.state.doc.toJSON()
}
/**
* Get the document as HTML.
*/
public getHTML(): string {
return getHTMLFromFragment(this.state.doc.content, this.schema)
}
/**
* Get the document as text.
*/
public getText(options?: { blockSeparator?: string; textSerializers?: Record<string, TextSerializer> }): string {
const { blockSeparator = '\n\n', textSerializers = {} } = options || {}
return getText(this.state.doc, {
blockSeparator,
textSerializers: {
...getTextSerializersFromSchema(this.schema),
...textSerializers,
},
})
}
/**
* Check if there is no content.
*/
public get isEmpty(): boolean {
return isNodeEmpty(this.state.doc)
}
/**
* Destroy the editor.
*/
public destroy(): void {
this.emit('destroy')
this.unmount()
this.removeAllListeners()
}
/**
* Check if the editor is already destroyed.
*/
public get isDestroyed(): boolean {
return this.editorView?.isDestroyed ?? true
}
public $node(selector: string, attributes?: { [key: string]: any }): NodePos | null {
return this.$doc?.querySelector(selector, attributes) || null
}
public $nodes(selector: string, attributes?: { [key: string]: any }): NodePos[] | null {
return this.$doc?.querySelectorAll(selector, attributes) || null
}
public $pos(pos: number) {
const $pos = this.state.doc.resolve(pos)
return new NodePos($pos, this)
}
get $doc() {
return this.$pos(0)
}
}