tiptap/packages/core/src/Editor.ts

448 lines
11 KiB
TypeScript
Raw Normal View History

2021-03-30 20:07:18 +08:00
import {
EditorState, Plugin, PluginKey, Transaction,
} from 'prosemirror-state'
2020-09-24 06:29:05 +08:00
import { EditorView } from 'prosemirror-view'
import { Schema } from 'prosemirror-model'
2020-11-30 16:42:53 +08:00
import getNodeAttributes from './helpers/getNodeAttributes'
import getMarkAttributes from './helpers/getMarkAttributes'
import isActive from './helpers/isActive'
import removeElement from './utilities/removeElement'
import createDocument from './helpers/createDocument'
2020-11-30 16:42:53 +08:00
import getHTMLFromFragment from './helpers/getHTMLFromFragment'
2021-03-25 05:23:08 +08:00
import isNodeEmpty from './helpers/isNodeEmpty'
2020-11-30 16:42:53 +08:00
import createStyleTag from './utilities/createStyleTag'
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,
SingleCommands,
AnyObject,
} 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
}
2020-03-06 06:59:48 +08:00
export class Editor extends EventEmitter {
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-04-01 22:21:47 +08:00
private resizeObserver!: ResizeObserver
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,
2020-11-30 20:56:42 +08:00
onCreate: () => null,
2020-11-17 22:27:00 +08:00
onUpdate: () => null,
onSelectionUpdate: () => null,
onViewUpdate: () => null,
2020-11-17 22:27:00 +08:00
onTransaction: () => null,
onFocus: () => null,
onBlur: () => null,
2021-04-01 22:21:47 +08:00
onResize: () => 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.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)
this.on('viewUpdate', this.options.onViewUpdate)
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(() => {
2020-11-17 22:47:39 +08:00
this.commands.focus(this.options.autofocus)
this.emit('create', { editor: this })
2021-04-01 22:21:47 +08:00
if (window.ResizeObserver) {
this.resizeObserver = new ResizeObserver(() => {
this.emit('resize', { editor: this })
})
this.resizeObserver.observe(this.view.dom)
}
2020-11-17 22:27:00 +08:00
}, 0)
2021-04-01 22:21:47 +08:00
2019-12-08 07:16:44 +08:00
}
/**
* An object of all registered commands.
*/
2021-01-28 16:04:55 +08:00
public get commands(): SingleCommands {
return this.commandManager.createCommands()
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 {
2020-09-23 03:25:32 +08:00
return this.commandManager.createChain()
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 {
2020-11-03 00:18:12 +08:00
return this.commandManager.createCan()
}
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 {
2020-08-22 03:53:45 +08:00
this.options = { ...this.options, ...options }
}
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-10-01 04:43:58 +08:00
if (this.view && this.state && !this.isDestroyed) {
2020-08-22 03:53:45 +08:00
this.view.updateState(this.state)
}
}
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 {
2020-08-22 04:08:54 +08:00
return this.view && this.view.editable
}
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 = typeof handlePlugins === 'function'
? 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
*
2020-08-22 05:35:15 +08:00
* @param name The plugins name
*/
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 {
2020-11-16 16:43:17 +08:00
const coreExtensions = Object.entries(extensions).map(([, extension]) => extension)
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 {
2021-02-17 01:54:44 +08:00
this.commandManager = new CommandManager(this, this.extensionManager.commands)
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 {
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: createDocument(this.options.content, this.schema, this.options.parseOptions),
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: [
new Plugin({
view: () => ({
update: () => this.emit('viewUpdate', {
editor: this,
}),
}),
}),
...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-03-15 20:27:52 +08:00
if (transaction.docChanged && !this.isEditable) {
return
}
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,
})
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,
})
2020-11-17 22:27:00 +08:00
}
if (blur) {
this.emit('blur', {
editor: this,
event: blur.event,
})
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
2020-08-22 05:35:15 +08:00
/**
* Get attributes of the currently selected node.
2020-09-03 21:11:55 +08:00
*
2020-08-22 05:35:15 +08:00
* @param name Name of the node
*/
2021-01-28 16:04:55 +08:00
public getNodeAttributes(name: string): AnyObject {
2020-11-30 16:21:31 +08:00
return getNodeAttributes(this.state, name)
2020-04-11 17:45:41 +08:00
}
2020-08-22 05:35:15 +08:00
/**
* Get attributes of the currently selected mark.
2020-09-03 21:11:55 +08:00
*
2020-08-22 05:35:15 +08:00
* @param name Name of the mark
*/
2021-01-28 16:04:55 +08:00
public getMarkAttributes(name: string): AnyObject {
2020-11-30 16:21:31 +08:00
return getMarkAttributes(this.state, name)
2020-03-31 18:53:52 +08:00
}
2020-03-30 20:49:48 +08:00
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.
*/
2021-01-28 16:04:55 +08:00
public getJSON(): AnyObject {
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 {
2020-10-29 00:20:38 +08:00
return getHTMLFromFragment(this.state.doc, this.schema)
2020-03-04 17:21:48 +08:00
}
2020-03-05 04:45:49 +08:00
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 {
2021-04-01 22:21:47 +08:00
this.resizeObserver?.unobserve(this.view.dom)
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-31 19:06:34 +08:00
removeElement(this.css)
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
}