UNPKG

tiptap

Version:

A rich-text editor for Vue.js

531 lines (457 loc) 12.5 kB
import { EditorState, Plugin, PluginKey, TextSelection, } from 'prosemirror-state' import { EditorView } from 'prosemirror-view' import { Schema, DOMParser, DOMSerializer } from 'prosemirror-model' import { dropCursor } from 'prosemirror-dropcursor' import { gapCursor } from 'prosemirror-gapcursor' import { keymap } from 'prosemirror-keymap' import { baseKeymap } from 'prosemirror-commands' import { inputRules, undoInputRule } from 'prosemirror-inputrules' import { markIsActive, nodeIsActive, getMarkAttrs, getNodeAttrs, } from 'tiptap-utils' import { injectCSS, camelCase, Emitter, ExtensionManager, ComponentView, minMax, } from './Utils' import { Doc, Paragraph, Text } from './Nodes' import css from './style.css' export default class Editor extends Emitter { constructor(options = {}) { super() this.defaultOptions = { editorProps: {}, editable: true, autoFocus: null, extensions: [], content: '', topNode: 'doc', emptyDocument: { type: 'doc', content: [{ type: 'paragraph', }], }, useBuiltInExtensions: true, disableInputRules: false, disablePasteRules: false, dropCursor: {}, enableDropCursor: true, enableGapCursor: true, parseOptions: {}, injectCSS: true, onInit: () => {}, onTransaction: () => {}, onUpdate: () => {}, onFocus: () => {}, onBlur: () => {}, onPaste: () => {}, onDrop: () => {}, } this.events = [ 'init', 'transaction', 'update', 'focus', 'blur', 'paste', 'drop', ] this.init(options) } init(options = {}) { this.setOptions({ ...this.defaultOptions, ...options, }) this.focused = false this.selection = { from: 0, to: 0 } this.element = document.createElement('div') this.extensions = this.createExtensions() this.nodes = this.createNodes() this.marks = this.createMarks() this.schema = this.createSchema() this.plugins = this.createPlugins() this.keymaps = this.createKeymaps() this.inputRules = this.createInputRules() this.pasteRules = this.createPasteRules() this.view = this.createView() this.commands = this.createCommands() this.setActiveNodesAndMarks() if (this.options.injectCSS) { injectCSS(css) } if (this.options.autoFocus !== null) { this.focus(this.options.autoFocus) } this.events.forEach(name => { this.on(name, this.options[camelCase(`on ${name}`)] || (() => {})) }) this.emit('init', { view: this.view, state: this.state, }) // give extension manager access to our view this.extensions.view = this.view } setOptions(options) { this.options = { ...this.options, ...options, } if (this.view && this.state) { this.view.updateState(this.state) } } get builtInExtensions() { if (!this.options.useBuiltInExtensions) { return [] } return [ new Doc(), new Text(), new Paragraph(), ] } get state() { return this.view ? this.view.state : null } createExtensions() { return new ExtensionManager([ ...this.builtInExtensions, ...this.options.extensions, ], this) } createPlugins() { return this.extensions.plugins } createKeymaps() { return this.extensions.keymaps({ schema: this.schema, }) } createInputRules() { return this.extensions.inputRules({ schema: this.schema, excludedExtensions: this.options.disableInputRules, }) } createPasteRules() { return this.extensions.pasteRules({ schema: this.schema, excludedExtensions: this.options.disablePasteRules, }) } createCommands() { return this.extensions.commands({ schema: this.schema, view: this.view, }) } createNodes() { return this.extensions.nodes } createMarks() { return this.extensions.marks } createSchema() { return new Schema({ topNode: this.options.topNode, nodes: this.nodes, marks: this.marks, }) } createState() { return EditorState.create({ schema: this.schema, doc: this.createDocument(this.options.content), plugins: [ ...this.plugins, inputRules({ rules: this.inputRules, }), ...this.pasteRules, ...this.keymaps, keymap({ Backspace: undoInputRule, }), keymap(baseKeymap), ...(this.options.enableDropCursor ? [dropCursor(this.options.dropCursor)] : []), ...(this.options.enableGapCursor ? [gapCursor()] : []), new Plugin({ key: new PluginKey('editable'), props: { editable: () => this.options.editable, }, }), new Plugin({ props: { attributes: { tabindex: 0, }, handleDOMEvents: { focus: (view, event) => { this.focused = true this.emit('focus', { event, state: view.state, view, }) const transaction = this.state.tr.setMeta('focused', true) this.view.dispatch(transaction) }, blur: (view, event) => { this.focused = false this.emit('blur', { event, state: view.state, view, }) const transaction = this.state.tr.setMeta('focused', false) this.view.dispatch(transaction) }, }, }, }), new Plugin({ props: this.options.editorProps, }), ], }) } createDocument(content, parseOptions = this.options.parseOptions) { if (content === null) { return this.schema.nodeFromJSON(this.options.emptyDocument) } if (typeof content === 'object') { try { return this.schema.nodeFromJSON(content) } catch (error) { console.warn('[tiptap warn]: Invalid content.', 'Passed value:', content, 'Error:', error) return this.schema.nodeFromJSON(this.options.emptyDocument) } } if (typeof content === 'string') { const htmlString = `<div>${content}</div>` const parser = new window.DOMParser() const element = parser.parseFromString(htmlString, 'text/html').body.firstElementChild return DOMParser.fromSchema(this.schema).parse(element, parseOptions) } return false } createView() { return new EditorView(this.element, { state: this.createState(), handlePaste: (...args) => { this.emit('paste', ...args) }, handleDrop: (...args) => { this.emit('drop', ...args) }, dispatchTransaction: this.dispatchTransaction.bind(this), }) } setParentComponent(component = null) { if (!component) { return } this.view.setProps({ nodeViews: this.initNodeViews({ parent: component, extensions: [ ...this.builtInExtensions, ...this.options.extensions, ], }), }) } initNodeViews({ parent, extensions }) { return extensions .filter(extension => ['node', 'mark'].includes(extension.type)) .filter(extension => extension.view) .reduce((nodeViews, extension) => { const nodeView = (node, view, getPos, decorations) => { const component = extension.view return new ComponentView(component, { editor: this, extension, parent, node, view, getPos, decorations, }) } return { ...nodeViews, [extension.name]: nodeView, } }, {}) } dispatchTransaction(transaction) { const newState = this.state.apply(transaction) this.view.updateState(newState) this.selection = { from: this.state.selection.from, to: this.state.selection.to, } this.setActiveNodesAndMarks() this.emit('transaction', { getHTML: this.getHTML.bind(this), getJSON: this.getJSON.bind(this), state: this.state, transaction, }) if (!transaction.docChanged || transaction.getMeta('preventUpdate')) { return } this.emitUpdate(transaction) } emitUpdate(transaction) { this.emit('update', { getHTML: this.getHTML.bind(this), getJSON: this.getJSON.bind(this), state: this.state, transaction, }) } resolveSelection(position = null) { if (this.selection && position === null) { return this.selection } if (position === 'start' || position === true) { return { from: 0, to: 0, } } if (position === 'end') { const { doc } = this.state return { from: doc.content.size, to: doc.content.size, } } return { from: position, to: position, } } focus(position = null) { if ((this.view.focused && position === null) || position === false) { return } const { from, to } = this.resolveSelection(position) this.setSelection(from, to) setTimeout(() => this.view.focus(), 10) } setSelection(from = 0, to = 0) { const { doc, tr } = this.state const resolvedFrom = minMax(from, 0, doc.content.size) const resolvedEnd = minMax(to, 0, doc.content.size) const selection = TextSelection.create(doc, resolvedFrom, resolvedEnd) const transaction = tr.setSelection(selection) this.view.dispatch(transaction) } blur() { this.view.dom.blur() } getSchemaJSON() { return JSON.parse(JSON.stringify({ nodes: this.extensions.nodes, marks: this.extensions.marks, })) } getHTML() { const div = document.createElement('div') const fragment = DOMSerializer .fromSchema(this.schema) .serializeFragment(this.state.doc.content) div.appendChild(fragment) return div.innerHTML } getJSON() { return this.state.doc.toJSON() } setContent(content = {}, emitUpdate = false, parseOptions) { const { doc, tr } = this.state const document = this.createDocument(content, parseOptions) const selection = TextSelection.create(doc, 0, doc.content.size) const transaction = tr .setSelection(selection) .replaceSelectionWith(document, false) .setMeta('preventUpdate', !emitUpdate) this.view.dispatch(transaction) } clearContent(emitUpdate = false) { this.setContent(this.options.emptyDocument, emitUpdate) } setActiveNodesAndMarks() { this.activeMarks = Object .entries(this.schema.marks) .reduce((marks, [name, mark]) => ({ ...marks, [name]: (attrs = {}) => markIsActive(this.state, mark, attrs), }), {}) this.activeMarkAttrs = Object .entries(this.schema.marks) .reduce((marks, [name, mark]) => ({ ...marks, [name]: getMarkAttrs(this.state, mark), }), {}) this.activeNodes = Object .entries(this.schema.nodes) .reduce((nodes, [name, node]) => ({ ...nodes, [name]: (attrs = {}) => nodeIsActive(this.state, node, attrs), }), {}) } getMarkAttrs(type = null) { return this.activeMarkAttrs[type] } getNodeAttrs(type = null) { return { ...getNodeAttrs(this.state, this.schema.nodes[type]), } } get isActive() { return Object .entries({ ...this.activeMarks, ...this.activeNodes, }) .reduce((types, [name, value]) => ({ ...types, [name]: (attrs = {}) => value(attrs), }), {}) } registerPlugin(plugin = null, handlePlugins) { const plugins = typeof handlePlugins === 'function' ? handlePlugins(plugin, this.state.plugins) : [plugin, ...this.state.plugins] const newState = this.state.reconfigure({ plugins }) this.view.updateState(newState) } unregisterPlugin(name = null) { if (!name || !this.view.docView) { return } const newState = this.state.reconfigure({ plugins: this.state.plugins.filter(plugin => !plugin.key.startsWith(`${name}$`)), }) this.view.updateState(newState) } destroy() { if (!this.view) { return } this.view.destroy() } }