tiptap/packages/core/src/ExtensionManager.ts
Philipp Kühn 4303637a78
feat: Allow to use commands within InputRule and PasteRule (#2035)
* add optional state prop to commandmanager

* add commands, chain and can getter to commandmanager

* use custom CommandManager for input rules and paste rules

* export commandmanager
2021-10-14 11:56:40 +02:00

351 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { keymap } from 'prosemirror-keymap'
import { Schema, Node as ProsemirrorNode } from 'prosemirror-model'
import { inputRulesPlugin } from './InputRule'
import { pasteRulesPlugin } from './PasteRule'
import { EditorView, Decoration } from 'prosemirror-view'
import { Plugin } from 'prosemirror-state'
import { Editor } from './Editor'
import { Extensions, RawCommands, AnyConfig } from './types'
import getExtensionField from './helpers/getExtensionField'
import getSchemaByResolvedExtensions from './helpers/getSchemaByResolvedExtensions'
import getSchemaTypeByName from './helpers/getSchemaTypeByName'
import getNodeType from './helpers/getNodeType'
import splitExtensions from './helpers/splitExtensions'
import getAttributesFromExtensions from './helpers/getAttributesFromExtensions'
import getRenderedAttributes from './helpers/getRenderedAttributes'
import callOrReturn from './utilities/callOrReturn'
import { NodeConfig } from '.'
export default class ExtensionManager {
editor: Editor
schema: Schema
extensions: Extensions
splittableMarks: string[] = []
constructor(extensions: Extensions, editor: Editor) {
this.editor = editor
this.extensions = ExtensionManager.resolve(extensions)
this.schema = getSchemaByResolvedExtensions(this.extensions)
this.extensions.forEach(extension => {
const context = {
name: extension.name,
options: extension.options,
editor: this.editor,
type: getSchemaTypeByName(extension.name, this.schema),
}
if (extension.type === 'mark') {
const keepOnSplit = callOrReturn(getExtensionField(extension, 'keepOnSplit', context)) ?? true
if (keepOnSplit) {
this.splittableMarks.push(extension.name)
}
}
const onBeforeCreate = getExtensionField<AnyConfig['onBeforeCreate']>(
extension,
'onBeforeCreate',
context,
)
if (onBeforeCreate) {
this.editor.on('beforeCreate', onBeforeCreate)
}
const onCreate = getExtensionField<AnyConfig['onCreate']>(
extension,
'onCreate',
context,
)
if (onCreate) {
this.editor.on('create', onCreate)
}
const onUpdate = getExtensionField<AnyConfig['onUpdate']>(
extension,
'onUpdate',
context,
)
if (onUpdate) {
this.editor.on('update', onUpdate)
}
const onSelectionUpdate = getExtensionField<AnyConfig['onSelectionUpdate']>(
extension,
'onSelectionUpdate',
context,
)
if (onSelectionUpdate) {
this.editor.on('selectionUpdate', onSelectionUpdate)
}
const onTransaction = getExtensionField<AnyConfig['onTransaction']>(
extension,
'onTransaction',
context,
)
if (onTransaction) {
this.editor.on('transaction', onTransaction)
}
const onFocus = getExtensionField<AnyConfig['onFocus']>(
extension,
'onFocus',
context,
)
if (onFocus) {
this.editor.on('focus', onFocus)
}
const onBlur = getExtensionField<AnyConfig['onBlur']>(
extension,
'onBlur',
context,
)
if (onBlur) {
this.editor.on('blur', onBlur)
}
const onDestroy = getExtensionField<AnyConfig['onDestroy']>(
extension,
'onDestroy',
context,
)
if (onDestroy) {
this.editor.on('destroy', onDestroy)
}
})
}
static resolve(extensions: Extensions): Extensions {
return ExtensionManager.sort(ExtensionManager.flatten(extensions))
}
static flatten(extensions: Extensions): Extensions {
return extensions
.map(extension => {
const context = {
name: extension.name,
options: extension.options,
}
const addExtensions = getExtensionField<AnyConfig['addExtensions']>(
extension,
'addExtensions',
context,
)
if (addExtensions) {
return [
extension,
...this.flatten(addExtensions()),
]
}
return extension
})
// `Infinity` will break TypeScript so we set a number that is probably high enough
.flat(10)
}
static sort(extensions: Extensions): Extensions {
const defaultPriority = 100
return extensions.sort((a, b) => {
const priorityA = getExtensionField<AnyConfig['priority']>(a, 'priority') || defaultPriority
const priorityB = getExtensionField<AnyConfig['priority']>(b, 'priority') || defaultPriority
if (priorityA > priorityB) {
return -1
}
if (priorityA < priorityB) {
return 1
}
return 0
})
}
get commands(): RawCommands {
return this.extensions.reduce((commands, extension) => {
const context = {
name: extension.name,
options: extension.options,
editor: this.editor,
type: getSchemaTypeByName(extension.name, this.schema),
}
const addCommands = getExtensionField<AnyConfig['addCommands']>(
extension,
'addCommands',
context,
)
if (!addCommands) {
return commands
}
return {
...commands,
...addCommands(),
}
}, {} as RawCommands)
}
get plugins(): Plugin[] {
const { editor } = this
// With ProseMirror, first plugins within an array are executed first.
// In tiptap, we provide the ability to override plugins,
// so it feels more natural to run plugins at the end of an array first.
// Thats why we have to reverse the `extensions` array and sort again
// based on the `priority` option.
const extensions = ExtensionManager.sort([...this.extensions].reverse())
const inputRules: any[] = []
const pasteRules: any[] = []
const allPlugins = extensions
.map(extension => {
const context = {
name: extension.name,
options: extension.options,
editor,
type: getSchemaTypeByName(extension.name, this.schema),
}
const plugins: Plugin[] = []
const addKeyboardShortcuts = getExtensionField<AnyConfig['addKeyboardShortcuts']>(
extension,
'addKeyboardShortcuts',
context,
)
if (addKeyboardShortcuts) {
const bindings = Object.fromEntries(
Object
.entries(addKeyboardShortcuts())
.map(([shortcut, method]) => {
return [shortcut, () => method({ editor })]
}),
)
const keyMapPlugin = keymap(bindings)
plugins.push(keyMapPlugin)
}
const addInputRules = getExtensionField<AnyConfig['addInputRules']>(
extension,
'addInputRules',
context,
)
if (editor.options.enableInputRules && addInputRules) {
inputRules.push(...addInputRules())
}
const addPasteRules = getExtensionField<AnyConfig['addPasteRules']>(
extension,
'addPasteRules',
context,
)
if (editor.options.enablePasteRules && addPasteRules) {
pasteRules.push(...addPasteRules())
}
const addProseMirrorPlugins = getExtensionField<AnyConfig['addProseMirrorPlugins']>(
extension,
'addProseMirrorPlugins',
context,
)
if (addProseMirrorPlugins) {
const proseMirrorPlugins = addProseMirrorPlugins()
plugins.push(...proseMirrorPlugins)
}
return plugins
})
.flat()
return [
inputRulesPlugin({
editor,
rules: inputRules,
}),
pasteRulesPlugin({
editor,
rules: pasteRules,
}),
...allPlugins,
]
}
get attributes() {
return getAttributesFromExtensions(this.extensions)
}
get nodeViews() {
const { editor } = this
const { nodeExtensions } = splitExtensions(this.extensions)
return Object.fromEntries(nodeExtensions
.filter(extension => !!getExtensionField(extension, 'addNodeView'))
.map(extension => {
const extensionAttributes = this.attributes.filter(attribute => attribute.type === extension.name)
const context = {
name: extension.name,
options: extension.options,
editor,
type: getNodeType(extension.name, this.schema),
}
const addNodeView = getExtensionField<NodeConfig['addNodeView']>(
extension,
'addNodeView',
context,
)
if (!addNodeView) {
return []
}
const nodeview = (
node: ProsemirrorNode,
view: EditorView,
getPos: (() => number) | boolean,
decorations: Decoration[],
) => {
const HTMLAttributes = getRenderedAttributes(node, extensionAttributes)
return addNodeView()({
editor,
node,
getPos,
decorations,
HTMLAttributes,
extension,
})
}
return [extension.name, nodeview]
}))
}
}