tiptap/packages/core/src/Editor.ts

479 lines
11 KiB
TypeScript
Raw Normal View History

2021-03-30 20:07:18 +08:00
import {
EditorState,
Plugin,
PluginKey,
Transaction,
2021-03-30 20:07:18 +08:00
} from 'prosemirror-state'
2020-09-24 06:29:05 +08:00
import { EditorView } from 'prosemirror-view'
import { Schema, MarkType, NodeType } from 'prosemirror-model'
import getAttributes from './helpers/getAttributes'
2020-11-30 16:42:53 +08:00
import isActive from './helpers/isActive'
import createDocument from './helpers/createDocument'
2020-11-30 16:42:53 +08:00
import getHTMLFromFragment from './helpers/getHTMLFromFragment'
import getText from './helpers/getText'
2021-03-25 05:23:08 +08:00
import isNodeEmpty from './helpers/isNodeEmpty'
import resolveFocusPosition from './helpers/resolveFocusPosition'
import getTextSeralizersFromSchema from './helpers/getTextSeralizersFromSchema'
2020-11-30 16:42:53 +08:00
import createStyleTag from './utilities/createStyleTag'
import isFunction from './utilities/isFunction'
2020-09-23 03:25:32 +08:00
import CommandManager from './CommandManager'
2020-03-06 04:05:01 +08:00
import ExtensionManager from './ExtensionManager'
2020-04-02 03:15:23 +08:00
import EventEmitter from './EventEmitter'
2021-01-28 16:04:55 +08:00
import {
EditorOptions,
CanCommands,
ChainedCommands,
JSONContent,
2021-01-28 16:04:55 +08:00
SingleCommands,
2021-09-30 15:34:45 +08:00
TextSerializer,
EditorEvents,
2021-01-28 16:04:55 +08:00
} from './types'
2020-10-23 16:44:30 +08:00
import * as extensions from './extensions'
2020-09-30 23:12:17 +08:00
import style from './style'
2020-08-21 05:25:55 +08:00
export { extensions }
2020-11-17 04:54:40 +08:00
export interface HTMLElement {
2020-09-12 00:06:13 +08:00
editor?: Editor
}
export class Editor extends EventEmitter<EditorEvents> {
2019-12-08 07:16:44 +08:00
2020-09-23 03:25:32 +08:00
private commandManager!: CommandManager
2020-09-24 06:29:05 +08:00
2021-01-20 03:27:51 +08:00
public extensionManager!: ExtensionManager
2020-09-24 06:29:05 +08:00
2020-08-18 15:36:37 +08:00
private css!: HTMLStyleElement
2020-09-24 06:29:05 +08:00
2020-08-18 15:36:37 +08:00
public schema!: Schema
2020-09-24 06:29:05 +08:00
2020-08-18 15:36:37 +08:00
public view!: EditorView
2020-09-24 06:29:05 +08:00
2020-08-22 04:08:54 +08:00
public isFocused = false
2020-09-24 06:29:05 +08:00
2021-10-22 14:52:54 +08:00
public extensionStorage: Record<string, any> = {}
2020-08-18 15:41:31 +08:00
public options: EditorOptions = {
2020-04-11 20:33:58 +08:00
element: document.createElement('div'),
2020-03-05 05:40:08 +08:00
content: '',
injectCSS: true,
2020-03-06 03:30:58 +08:00
extensions: [],
2020-11-17 22:47:39 +08:00
autofocus: false,
2020-08-22 03:53:45 +08:00
editable: true,
2020-11-18 04:21:19 +08:00
editorProps: {},
2020-11-18 04:15:10 +08:00
parseOptions: {},
enableInputRules: true,
enablePasteRules: true,
enableCoreExtensions: true,
2021-04-02 06:07:40 +08:00
onBeforeCreate: () => null,
2020-11-30 20:56:42 +08:00
onCreate: () => null,
2020-11-17 22:27:00 +08:00
onUpdate: () => null,
onSelectionUpdate: () => null,
2020-11-17 22:27:00 +08:00
onTransaction: () => null,
onFocus: () => null,
onBlur: () => null,
2020-11-30 20:56:42 +08:00
onDestroy: () => null,
2020-03-05 05:40:08 +08:00
}
2020-08-11 22:57:11 +08:00
2020-04-14 16:13:27 +08:00
constructor(options: Partial<EditorOptions> = {}) {
2020-03-06 06:59:48 +08:00
super()
this.setOptions(options)
2020-03-06 04:49:53 +08:00
this.createExtensionManager()
2021-02-10 21:52:08 +08:00
this.createCommandManager()
2020-03-06 04:49:53 +08:00
this.createSchema()
this.on('beforeCreate', this.options.onBeforeCreate)
2021-04-02 06:07:40 +08:00
this.emit('beforeCreate', { editor: this })
2020-03-06 04:49:53 +08:00
this.createView()
2020-09-30 23:12:17 +08:00
this.injectCSS()
2020-11-30 20:56:42 +08:00
this.on('create', this.options.onCreate)
2020-11-17 22:27:00 +08:00
this.on('update', this.options.onUpdate)
this.on('selectionUpdate', this.options.onSelectionUpdate)
2020-11-17 22:27:00 +08:00
this.on('transaction', this.options.onTransaction)
this.on('focus', this.options.onFocus)
this.on('blur', this.options.onBlur)
2020-11-30 20:56:42 +08:00
this.on('destroy', this.options.onDestroy)
2020-11-17 22:27:00 +08:00
window.setTimeout(() => {
if (this.isDestroyed) {
return
}
2020-11-17 22:47:39 +08:00
this.commands.focus(this.options.autofocus)
this.emit('create', { editor: this })
2020-11-17 22:27:00 +08:00
}, 0)
2019-12-08 07:16:44 +08:00
}
2021-10-22 14:52:54 +08:00
/**
* Returns the editor storage.
*/
public get storage(): Record<string, any> {
return this.extensionStorage
}
/**
* An object of all registered commands.
*/
2021-01-28 16:04:55 +08:00
public get commands(): SingleCommands {
return this.commandManager.commands
2020-11-13 16:58:30 +08:00
}
2020-09-23 03:25:32 +08:00
/**
* Create a command chain to call multiple commands at once.
*/
2021-01-28 16:04:55 +08:00
public chain(): ChainedCommands {
return this.commandManager.chain()
2020-09-22 00:40:32 +08:00
}
2020-11-03 00:18:12 +08:00
/**
* Check if a command or a command chain can be executed. Without executing it.
*/
2021-01-28 16:04:55 +08:00
public can(): CanCommands {
return this.commandManager.can()
2020-11-03 00:18:12 +08:00
}
2020-09-30 23:12:17 +08:00
/**
* Inject CSS styles.
*/
2021-01-28 16:04:55 +08:00
private injectCSS(): void {
2020-09-30 23:12:17 +08:00
if (this.options.injectCSS && document) {
this.css = createStyleTag(style)
}
}
2020-08-22 05:35:15 +08:00
/**
* Update editor options.
2020-09-03 21:11:55 +08:00
*
2020-08-22 05:35:15 +08:00
* @param options A list of options
*/
2021-01-28 16:04:55 +08:00
public setOptions(options: Partial<EditorOptions> = {}): void {
2021-08-09 23:24:18 +08:00
this.options = {
...this.options,
...options,
}
if (!this.view || !this.state || this.isDestroyed) {
return
}
2021-08-09 23:24:18 +08:00
if (this.options.editorProps) {
this.view.setProps(this.options.editorProps)
}
2021-08-09 23:24:18 +08:00
this.view.updateState(this.state)
}
2021-03-05 19:15:50 +08:00
/**
* Update editable state of the editor.
*/
public setEditable(editable: boolean): void {
this.setOptions({ editable })
2020-08-22 03:53:45 +08:00
}
2020-09-03 21:11:55 +08:00
2020-08-22 05:35:15 +08:00
/**
* Returns whether the editor is editable.
*/
2021-01-28 16:04:55 +08:00
public get isEditable(): boolean {
// since plugins are applied after creating the view
// `editable` is always `true` for one tick.
// thats why we also have to check for `options.editable`
return this.options.editable
&& this.view
&& this.view.editable
2020-08-22 04:08:54 +08:00
}
2020-08-22 05:35:15 +08:00
/**
* Returns the editor state.
*/
2021-01-28 16:04:55 +08:00
public get state(): EditorState {
2020-03-29 07:21:28 +08:00
return this.view.state
}
2020-08-22 05:35:15 +08:00
/**
* Register a ProseMirror plugin.
2020-09-03 21:11:55 +08:00
*
2020-08-22 05:35:15 +08:00
* @param plugin A ProseMirror plugin
* @param handlePlugins Control how to merge the plugin into the existing plugins.
*/
2021-01-28 16:04:55 +08:00
public registerPlugin(plugin: Plugin, handlePlugins?: (newPlugin: Plugin, plugins: Plugin[]) => Plugin[]): void {
const plugins = isFunction(handlePlugins)
? handlePlugins(plugin, this.state.plugins)
2021-01-29 00:39:57 +08:00
: [...this.state.plugins, plugin]
2020-04-11 03:43:23 +08:00
const state = this.state.reconfigure({ plugins })
2020-04-11 03:43:23 +08:00
this.view.updateState(state)
}
2020-08-22 05:35:15 +08:00
/**
* Unregister a ProseMirror plugin.
2020-09-03 21:11:55 +08:00
*
* @param nameOrPluginKey The plugins name
2020-08-22 05:35:15 +08:00
*/
2021-03-30 20:07:18 +08:00
public unregisterPlugin(nameOrPluginKey: string | PluginKey): void {
if (this.isDestroyed) {
return
}
const name = typeof nameOrPluginKey === 'string'
? `${nameOrPluginKey}$`
// @ts-ignore
: nameOrPluginKey.key
2020-04-11 04:55:14 +08:00
const state = this.state.reconfigure({
// @ts-ignore
2021-03-30 20:07:18 +08:00
plugins: this.state.plugins.filter(plugin => !plugin.key.startsWith(name)),
2020-04-11 04:55:14 +08:00
})
this.view.updateState(state)
}
2020-08-22 05:35:15 +08:00
/**
* Creates an extension manager.
*/
2021-01-28 16:04:55 +08:00
private createExtensionManager(): void {
const coreExtensions = this.options.enableCoreExtensions
2021-09-22 02:21:12 +08:00
? Object.values(extensions)
: []
2021-01-29 00:39:57 +08:00
const allExtensions = [...coreExtensions, ...this.options.extensions].filter(extension => {
2020-12-22 00:43:29 +08:00
return ['extension', 'node', 'mark'].includes(extension?.type)
})
2020-10-23 16:44:30 +08:00
2021-02-17 01:54:44 +08:00
this.extensionManager = new ExtensionManager(allExtensions, this)
2019-12-08 07:16:44 +08:00
}
2020-09-23 03:25:32 +08:00
/**
* Creates an command manager.
*/
2021-01-28 16:04:55 +08:00
private createCommandManager(): void {
this.commandManager = new CommandManager({
editor: this,
})
2020-09-23 03:25:32 +08:00
}
2020-08-22 05:35:15 +08:00
/**
* Creates a ProseMirror schema.
*/
2021-01-28 16:04:55 +08:00
private createSchema(): void {
2020-09-10 06:09:05 +08:00
this.schema = this.extensionManager.schema
2020-03-06 04:05:01 +08:00
}
2020-08-22 05:35:15 +08:00
/**
* Creates a ProseMirror view.
*/
2021-01-28 16:04:55 +08:00
private createView(): void {
const doc = createDocument(this.options.content, this.schema, this.options.parseOptions)
const selection = resolveFocusPosition(doc, this.options.autofocus)
2021-12-03 17:36:51 +08:00
2020-04-11 20:33:58 +08:00
this.view = new EditorView(this.options.element, {
2020-11-18 04:21:19 +08:00
...this.options.editorProps,
2020-11-04 22:31:42 +08:00
dispatchTransaction: this.dispatchTransaction.bind(this),
2020-03-06 04:49:53 +08:00
state: EditorState.create({
doc,
selection,
2020-03-06 04:49:53 +08:00
}),
})
// `editor.view` is not yet available at this time.
2020-11-04 22:31:42 +08:00
// Therefore we will add all plugins and node views directly afterwards.
const newState = this.state.reconfigure({
plugins: this.extensionManager.plugins,
2020-11-04 22:31:42 +08:00
})
this.view.updateState(newState)
this.createNodeViews()
2020-09-12 00:06:13 +08:00
// Lets store the editor instance in the DOM element.
// So well have access to it for tests.
2020-09-12 00:06:13 +08:00
const dom = this.view.dom as HTMLElement
2021-02-17 01:54:44 +08:00
dom.editor = this
2019-12-08 07:16:44 +08:00
}
/**
* Creates all node views.
*/
2021-01-28 16:04:55 +08:00
public createNodeViews(): void {
this.view.setProps({
nodeViews: this.extensionManager.nodeViews,
})
}
2021-02-09 17:06:13 +08:00
public isCapturingTransaction = false
private capturedTransaction: Transaction | null = null
public captureTransaction(fn: Function) {
this.isCapturingTransaction = true
fn()
this.isCapturingTransaction = false
const tr = this.capturedTransaction
this.capturedTransaction = null
return tr
}
2020-08-22 05:35:15 +08:00
/**
* The callback over which to send transactions (state updates) produced by the view.
2020-09-03 21:11:55 +08:00
*
2020-08-22 05:35:15 +08:00
* @param transaction An editor state transaction
*/
2021-01-28 16:04:55 +08:00
private dispatchTransaction(transaction: Transaction): void {
2021-02-09 17:06:13 +08:00
if (this.isCapturingTransaction) {
if (!this.capturedTransaction) {
this.capturedTransaction = transaction
return
}
transaction.steps.forEach(step => this.capturedTransaction?.step(step))
return
}
2020-03-05 05:40:08 +08:00
const state = this.state.apply(transaction)
2020-11-27 21:52:19 +08:00
const selectionHasChanged = !this.state.selection.eq(state.selection)
2020-11-17 22:27:00 +08:00
2020-03-05 05:40:08 +08:00
this.view.updateState(state)
this.emit('transaction', {
editor: this,
transaction,
})
2020-08-11 22:57:11 +08:00
2020-11-27 21:52:19 +08:00
if (selectionHasChanged) {
this.emit('selectionUpdate', {
editor: this,
transaction,
})
2020-11-27 21:52:19 +08:00
}
2020-11-17 22:27:00 +08:00
const focus = transaction.getMeta('focus')
const blur = transaction.getMeta('blur')
if (focus) {
this.emit('focus', {
editor: this,
event: focus.event,
transaction,
})
2020-11-17 22:27:00 +08:00
}
if (blur) {
this.emit('blur', {
editor: this,
event: blur.event,
transaction,
})
2020-11-17 22:27:00 +08:00
}
2019-12-08 07:16:44 +08:00
if (!transaction.docChanged || transaction.getMeta('preventUpdate')) {
return
}
this.emit('update', {
editor: this,
transaction,
})
2019-12-08 07:16:44 +08:00
}
2019-12-17 06:51:18 +08:00
/**
* Get attributes of the currently selected node or mark.
*/
public getAttributes(nameOrType: string | NodeType | MarkType): Record<string, any> {
return getAttributes(this.state, nameOrType)
}
2020-08-22 05:35:15 +08:00
/**
* Returns if the currently selected node or mark is active.
2020-09-03 21:11:55 +08:00
*
2020-08-22 05:35:15 +08:00
* @param name Name of the node or mark
* @param attributes Attributes of the node or mark
2020-08-22 05:35:15 +08:00
*/
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)
2020-03-06 04:49:53 +08:00
}
2020-08-22 05:35:15 +08:00
/**
* Get the document as JSON.
*/
public getJSON(): JSONContent {
2020-03-04 17:21:48 +08:00
return this.state.doc.toJSON()
}
2020-08-22 05:35:15 +08:00
/**
* Get the document as HTML.
*/
2021-01-28 16:04:55 +08:00
public getHTML(): string {
return getHTMLFromFragment(this.state.doc.content, this.schema)
2020-03-04 17:21:48 +08:00
}
2020-03-05 04:45:49 +08:00
/**
* 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: {
...textSerializers,
...getTextSeralizersFromSchema(this.schema),
},
})
}
2020-10-23 20:28:25 +08:00
/**
* Check if there is no content.
*/
2021-03-25 05:23:08 +08:00
public get isEmpty(): boolean {
return isNodeEmpty(this.state.doc)
2020-10-23 20:28:25 +08:00
}
/**
* Get the number of characters for the current document.
*/
2021-01-28 16:04:55 +08:00
public getCharacterCount(): number {
return this.state.doc.content.size - 2
}
2020-08-22 05:35:15 +08:00
/**
* Destroy the editor.
*/
2021-01-28 16:04:55 +08:00
public destroy(): void {
2020-11-30 20:50:06 +08:00
this.emit('destroy')
2020-03-31 19:07:57 +08:00
if (this.view) {
this.view.destroy()
2020-03-05 04:45:49 +08:00
}
2020-03-06 06:59:48 +08:00
this.removeAllListeners()
2020-03-05 04:45:49 +08:00
}
2020-09-24 06:29:05 +08:00
2020-10-01 04:43:58 +08:00
/**
* Check if the editor is already destroyed.
*/
public get isDestroyed(): boolean {
2020-10-01 04:43:58 +08:00
// @ts-ignore
return !this.view?.docView
}
2019-12-08 07:16:44 +08:00
}