diff --git a/demos/src/Examples/Default/React/index.jsx b/demos/src/Examples/Default/React/index.tsx similarity index 89% rename from demos/src/Examples/Default/React/index.jsx rename to demos/src/Examples/Default/React/index.tsx index 6f410db6fb..ecef83ebce 100644 --- a/demos/src/Examples/Default/React/index.jsx +++ b/demos/src/Examples/Default/React/index.tsx @@ -1,16 +1,16 @@ import './styles.scss' import ListItem from '@tiptap/extension-list-item' -import { Color , TextStyle } from '@tiptap/extension-text-style' -import { EditorProvider, useCurrentEditor, useEditorState } from '@tiptap/react' +import { Color, TextStyle } from '@tiptap/extension-text-style' +import { EditorProvider, InlineDecoration, useCurrentEditor, useEditorState } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' -import React from 'react' +import React, { useEffect } from 'react' const MenuBar = () => { const { editor } = useCurrentEditor() const editorState = useEditorState({ - editor, + editor: editor!, selector: ctx => { return { isBold: ctx.editor.isActive('bold'), @@ -35,11 +35,37 @@ const MenuBar = () => { isBlockquote: ctx.editor.isActive('blockquote'), canUndo: ctx.editor.can().chain().focus().undo().run(), canRedo: ctx.editor.can().chain().focus().redo().run(), - isPurple: editor.isActive('textStyle', { color: '#958DF1' }), + isPurple: ctx.editor.isActive('textStyle', { color: '#958DF1' }), } }, }) + useEffect(() => { + if (!editor) { + return + } + editor.decorationManager.create( + InlineDecoration.create({ + name: 'purple', + render: () => { + const span = document.createElement('span') + + span.textContent = '🟣' + return span + }, + addAttributes: () => { + return { + style: 'color: #958DF1', + } + }, + onChange() { + this.create({ from: 0, to: 1 }) + }, + }), + { target: { from: 2, to: 10 } }, + ) + }, [editor]) + if (!editor) { return null } @@ -164,7 +190,7 @@ const MenuBar = () => { const extensions = [ Color.configure({ types: [TextStyle.name, ListItem.name] }), - TextStyle.configure({ types: [ListItem.name] }), + TextStyle, StarterKit.configure({ bulletList: { keepMarks: true, diff --git a/demos/src/Examples/Default/React/styles.scss b/demos/src/Examples/Default/React/styles.scss index 15283b15a5..9cbc72381f 100644 --- a/demos/src/Examples/Default/React/styles.scss +++ b/demos/src/Examples/Default/React/styles.scss @@ -1,3 +1,6 @@ +.decoration { + color: red +} /* Basic editor styles */ .tiptap { :first-child { diff --git a/packages/core/src/Decoration.ts b/packages/core/src/Decoration.ts new file mode 100644 index 0000000000..9573e0e501 --- /dev/null +++ b/packages/core/src/Decoration.ts @@ -0,0 +1,603 @@ +// eslint-disable-next-line max-classes-per-file +import { Plugin, Transaction } from '@tiptap/pm/state' +import { Decoration as PMDecoration, DecorationAttrs } from '@tiptap/pm/view' + +import { Editor } from './Editor.js' +import { getExtensionField } from './helpers/getExtensionField.js' +import { DecorationConfig, InlineDecorationConfig, NodeDecorationConfig, WidgetDecorationConfig } from './index.js' +import { InputRule } from './InputRule.js' +import { Mark } from './Mark.js' +import { Node } from './Node.js' +import { PasteRule } from './PasteRule.js' +import { AnyConfig, Extensions, GlobalAttributes, KeyboardShortcutCommand, ParentConfig, RawCommands } from './types.js' +import { callOrReturn } from './utilities/callOrReturn.js' +import { mergeDeep } from './utilities/mergeDeep.js' + +declare module '@tiptap/core' { + interface DecorationConfig { + // @ts-ignore - this is a dynamic key + [key: string]: any + + /** + * The extension name - this must be unique. + * It will be used to identify the extension. + * + * @example 'myDecoration' + */ + name: string + + /** + * The priority of your extension. The higher, the earlier it will be called + * and will take precedence over other extensions with a lower priority. + * @default 100 + * @example 101 + */ + priority?: number + + /** + * This method will add options to this extension + * @see https://tiptap.dev/docs/editor/guide/custom-extensions#settings + * @example + * addOptions() { + * return { + * myOption: 'foo', + * myOtherOption: 10, + * } + */ + addOptions?: (this: { + name: string + parent: Exclude>['addOptions'], undefined> + }) => Options + + /** + * The default storage this extension can save data to. + * @see https://tiptap.dev/docs/editor/guide/custom-extensions#storage + * @example + * defaultStorage: { + * prefetchedUsers: [], + * loading: false, + * } + */ + addStorage?: (this: { + name: string + options: Options + parent: Exclude>['addStorage'], undefined> + }) => Storage + + /** + * This function adds globalAttributes to specific nodes. + * @see https://tiptap.dev/docs/editor/guide/custom-extensions#global-attributes + * @example + * addGlobalAttributes() { + * return [ + * { + // Extend the following extensions + * types: [ + * 'heading', + * 'paragraph', + * ], + * // … with those attributes + * attributes: { + * textAlign: { + * default: 'left', + * renderHTML: attributes => ({ + * style: `text-align: ${attributes.textAlign}`, + * }), + * parseHTML: element => element.style.textAlign || 'left', + * }, + * }, + * }, + * ] + * } + */ + addGlobalAttributes?: (this: { + name: string + options: Options + storage: Storage + extensions: (Node | Mark)[] + parent: ParentConfig>['addGlobalAttributes'] + }) => GlobalAttributes + + /** + * This function adds commands to the editor + * @see https://tiptap.dev/docs/editor/guide/custom-extensions#commands + * @example + * addCommands() { + * return { + * myCommand: () => ({ chain }) => chain().setMark('type', 'foo').run(), + * } + * } + */ + addCommands?: (this: { + name: string + options: Options + storage: Storage + editor: Editor + parent: ParentConfig>['addCommands'] + }) => Partial + + /** + * This function registers keyboard shortcuts. + * @see https://tiptap.dev/docs/editor/guide/custom-extensions#keyboard-shortcuts + * @example + * addKeyboardShortcuts() { + * return { + * 'Mod-l': () => this.editor.commands.toggleBulletList(), + * } + * }, + */ + addKeyboardShortcuts?: (this: { + name: string + options: Options + storage: Storage + editor: Editor + parent: ParentConfig>['addKeyboardShortcuts'] + }) => { + [key: string]: KeyboardShortcutCommand + } + + /** + * This function adds input rules to the editor. + * @see https://tiptap.dev/docs/editor/guide/custom-extensions#input-rules + * @example + * addInputRules() { + * return [ + * markInputRule({ + * find: inputRegex, + * type: this.type, + * }), + * ] + * }, + */ + addInputRules?: (this: { + name: string + options: Options + storage: Storage + editor: Editor + parent: ParentConfig>['addInputRules'] + }) => InputRule[] + + /** + * This function adds paste rules to the editor. + * @see https://tiptap.dev/docs/editor/guide/custom-extensions#paste-rules + * @example + * addPasteRules() { + * return [ + * markPasteRule({ + * find: pasteRegex, + * type: this.type, + * }), + * ] + * }, + */ + addPasteRules?: (this: { + name: string + options: Options + storage: Storage + editor: Editor + parent: ParentConfig>['addPasteRules'] + }) => PasteRule[] + + /** + * This function adds Prosemirror plugins to the editor + * @see https://tiptap.dev/docs/editor/guide/custom-extensions#prosemirror-plugins + * @example + * addProseMirrorPlugins() { + * return [ + * customPlugin(), + * ] + * } + */ + addProseMirrorPlugins?: (this: { + name: string + options: Options + storage: Storage + editor: Editor + parent: ParentConfig>['addProseMirrorPlugins'] + }) => Plugin[] + + /** + * This function adds additional extensions to the editor. This is useful for + * building extension kits. + * @example + * addExtensions() { + * return [ + * BulletList, + * OrderedList, + * ListItem + * ] + * } + */ + addExtensions?: (this: { + name: string + options: Options + storage: Storage + parent: ParentConfig>['addExtensions'] + }) => Extensions + + /** + * The editor is not ready yet. + */ + onBeforeCreate?: + | ((this: { + name: string + options: Options + storage: Storage + editor: Editor + parent: ParentConfig>['onBeforeCreate'] + }) => void) + | null + + /** + * The editor is ready. + */ + onCreate?: + | ((this: { + name: string + options: Options + storage: Storage + editor: Editor + parent: ParentConfig>['onCreate'] + }) => void) + | null + + /** + * The content has changed. + */ + onUpdate?: + | ((this: { + name: string + options: Options + storage: Storage + editor: Editor + parent: ParentConfig>['onUpdate'] + }) => void) + | null + + /** + * The selection has changed. + */ + onSelectionUpdate?: + | ((this: { + name: string + options: Options + storage: Storage + editor: Editor + parent: ParentConfig>['onSelectionUpdate'] + }) => void) + | null + + // /** + // * The editor state has changed. + // */ + // onTransaction?: + // | (( + // this: { + // name: string; + // options: Options; + // storage: Storage; + // editor: Editor; + // parent: ParentConfig>['onTransaction']; + // }, + // props: { + // editor: Editor; + // transaction: Transaction; + // } + // ) => void) + // | null; + + /** + * The editor is focused. + */ + onFocus?: + | (( + this: { + name: string + options: Options + storage: Storage + editor: Editor + parent: ParentConfig>['onFocus'] + }, + props: { + event: FocusEvent + }, + ) => void) + | null + + /** + * The editor isn’t focused anymore. + */ + onBlur?: + | (( + this: { + name: string + options: Options + storage: Storage + editor: Editor + parent: ParentConfig>['onBlur'] + }, + props: { + event: FocusEvent + }, + ) => void) + | null + + /** + * The editor is destroyed. + */ + onDestroy?: + | ((this: { + name: string + options: Options + storage: Storage + editor: Editor + parent: ParentConfig>['onDestroy'] + }) => void) + | null + } + + interface WidgetDecorationConfig extends Decoration { + // extends Parameters['2'] = Parameters['2'] + + /** + * Add attributes to the node + * @example addSpec: () => ({ ctx: 'foo' }) + */ + addSpec?: (this: { + name: string + options: Options + storage: Storage + parent: ParentConfig>['addSpec'] + editor?: Editor + }) => Parameters['2'] + + render: ( + this: { + name: string + options: Options + storage: Storage + parent: ParentConfig>['render'] + editor?: Editor + }, + ctx: { + editor: Editor + getPos: () => number | undefined + }, + ) => HTMLElement + } + + interface InlineDecorationConfig extends Decoration { + /** + * Add attributes to the node + * @example addSpec: () => ({ ctx: 'foo' }) + */ + addSpec?: (this: { + name: string + options: Options + storage: Storage + parent: ParentConfig>['addSpec'] + editor?: Editor + }) => Parameters['3'] + + /** + * Add attributes to the node + * @example addAttributes: () => ({ nodeName: 'span', class: 'foo', style: 'color: red' }) + */ + addAttributes?: (this: { + name: string + options: Options + storage: Storage + parent: ParentConfig>['addAttributes'] + editor?: Editor + }) => Partial + + /** + * The editor state has changed. + */ + onTransaction?: + | (( + this: { + name: string + options: Options + storage: Storage + editor: Editor + parent: ParentConfig>['onTransaction'] + instances: { + id: string + spec: { + instanceId: string + extension: Decoration + name: string + } & ReturnType['addAttributes'], undefined>> + decoration: InlineDecoration + }[] + }, + props: { + editor: Editor + transaction: Transaction + }, + ) => void) + | null + } + + interface NodeDecorationConfig extends Decoration { + /** + * Add attributes to the node + * @example addSpec: () => ({ ctx: 'foo' }) + */ + addSpec?: (this: { + name: string + options: Options + storage: Storage + parent: ParentConfig>['addSpec'] + editor?: Editor + }) => Parameters['3'] + + /** + * Add attributes to the node + * @example addAttributes: () => ({ nodeName: 'span', class: 'foo', style: 'color: red' }) + */ + addAttributes?: (this: { + name: string + options: Options + storage: Storage + parent: ParentConfig>['addAttributes'] + editor?: Editor + }) => Partial + } +} + +/** + * The Extension class is the base class for all extensions. + * @see https://tiptap.dev/api/extensions#create-a-new-extension + */ +export class Decoration { + type = 'decoration' + + name = 'decoration' + + decorationType: 'widget' | 'inline' | 'node' = 'node' + + parent: Decoration | null = null + + child: Decoration | null = null + + options: Options + + storage: Storage + + config: DecorationConfig = { + name: this.name, + } + + constructor(config: Partial> = {}) { + this.config = { + ...this.config, + ...config, + } + + this.name = this.config.name + + this.options = {} as Options + + if (this.config.addOptions) { + this.options = callOrReturn( + getExtensionField(this, 'addOptions', { + name: this.name, + }), + ) + } + + this.storage = + callOrReturn( + getExtensionField(this, 'addStorage', { + name: this.name, + options: this.options, + }), + ) || {} + } + + static create(config: Partial> = {}) { + return new Decoration(config) + } + + configure(options: Partial = {}) { + // return a new instance so we can use the same extension + // with different calls of `configure` + const extension = this.extend({ + ...this.config, + addOptions: () => { + return mergeDeep(this.options as Record, options) as Options + }, + }) + + // Always preserve the current name + extension.name = this.name + // Set the parent to be our parent + extension.parent = this.parent + + return extension + } + + extend( + extendedConfig: Partial> = {}, + ) { + const extension = new Decoration({ + ...this.config, + ...extendedConfig, + }) + + extension.parent = this + + this.child = extension + + extension.name = extendedConfig.name ? extendedConfig.name : extension.parent.name + + extension.options = callOrReturn( + getExtensionField(extension, 'addOptions', { + name: extension.name, + }), + ) + + extension.storage = callOrReturn( + getExtensionField(extension, 'addStorage', { + name: extension.name, + options: extension.options, + }), + ) + + return extension + } +} + +export class WidgetDecoration extends Decoration { + decorationType = 'widget' as const + + config: WidgetDecorationConfig = {} as WidgetDecorationConfig + + constructor(config: Partial> = {}) { + super(config) + this.config = { + ...this.config, + ...config, + } as WidgetDecorationConfig + } + + static create(config: Partial> = {}) { + return new WidgetDecoration(config) + } +} +export class InlineDecoration extends Decoration { + decorationType = 'inline' as const + + config: InlineDecorationConfig = {} as InlineDecorationConfig + + constructor(config: Partial> = {}) { + super(config) + this.config = { + ...this.config, + ...config, + } as InlineDecorationConfig + } + + static create(config: Partial> = {}) { + return new InlineDecoration(config) + } +} +export class NodeDecoration extends Decoration { + decorationType = 'node' as const + + config: NodeDecorationConfig = {} as NodeDecorationConfig + + constructor(config: Partial> = {}) { + super(config) + this.config = { + ...this.config, + ...config, + } as NodeDecorationConfig + } + + static create(config: Partial> = {}) { + return new InlineDecoration(config) + } +} diff --git a/packages/core/src/DecorationManager.ts b/packages/core/src/DecorationManager.ts new file mode 100644 index 0000000000..3a240a2bd4 --- /dev/null +++ b/packages/core/src/DecorationManager.ts @@ -0,0 +1,360 @@ +import { EditorState, Plugin, PluginKey, Selection, Transaction } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' + +import { Decoration as TiptapDecoration } from './Decoration.js' +import { Editor } from './Editor.js' +import { getChangedRanges, getExtensionField } from './helpers/index.js' +import { DecorationConfig, InlineDecorationConfig, WidgetDecorationConfig } from './index.js' +import { NodePos } from './NodePos.js' +import { Range } from './types.js' +import { callOrReturn } from './utilities/index.js' + +export type AttachToOptions = { + /** + * The target position to attach the decoration to. + */ + target: NodePos | Range | Selection | number + /** + * Force the decoration to be a specific type. + */ + type?: 'node' | 'inline' | 'widget' + /** + * Offset the target position by a number or an object with start and end properties. + */ + offset?: + | number + | { + start: number + end: number + } +} + +type ResolvedDecorationOptions = { + type: 'widget' | 'inline' | 'node' + from: number + to: number +} + +type DecorationManagerPluginState = DecorationSet + +export class DecorationManager { + static pluginKey = new PluginKey('tiptapDecorationManager') + + editor: Editor + + plugin: Plugin + + decorations: Map< + string, + { + decoration: Decoration + options: AttachToOptions + } + > = new Map() + + /** + * Decoration ids which have been rendered before, this allows us to lazily render new decorations. + */ + lastRenderedDecorations: string[] = [] + + decorationSet: DecorationSet = DecorationSet.empty + + constructor(props: { editor: Editor }) { + this.editor = props.editor + this.plugin = new Plugin({ + key: DecorationManager.pluginKey, + state: { + init: (_config, state) => this.getDecorationSet(state), + apply: (...args) => { + if (this.onApply(...args)) { + return this.getDecorationSet(args[3]) + } + return args[1] + }, + }, + props: { + decorations: this.getDecorationSet.bind(this), + }, + }) + } + + private getDecorationSet(state: EditorState): DecorationSet { + console.log('getting decoration set', state) + return this.decorationSet + } + + /** + * Runs on every transaction and updates the decoration set. + * @returns IF it should update the decoration set + */ + private onApply(tr: Transaction, decorations: DecorationSet, oldState: EditorState, newState: EditorState): boolean { + this.decorationSet = decorations.map(tr.mapping, tr.doc) + + const newDecorationIds = this.getNewDecorationIds() + + if (newDecorationIds.length) { + console.log('applying new decorations', newDecorationIds, oldState, newState) + this.decorationSet = this.decorationSet.add( + tr.doc, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + newDecorationIds.map(id => this.decorations.get(id)!.decoration), + ) + + this.lastRenderedDecorations = this.lastRenderedDecorations.concat(newDecorationIds) + } + console.log({ decorations }) + return true + } + + /** + * Get all decoration ids that have not yet been rendered. + */ + public getNewDecorationIds(): string[] { + if ( + this.lastRenderedDecorations.length !== this.decorations.size || + this.lastRenderedDecorations.some(id => !this.decorations.has(id)) + ) { + const allDecorationIds = Array.from(this.decorations.keys()) + + return allDecorationIds.filter(k => !this.lastRenderedDecorations.includes(k)) + } + + return [] as string[] + } + + /** + * Get the range of positions that have been affected by a transaction + */ + static getAffectedRange(tr: Transaction, oldState: EditorState, newState: EditorState) { + const docSize = newState.doc.nodeSize - 2 + let minFrom = 0 + let maxTo = docSize + + if (tr.docChanged) { + // When the document changes, only run on the nodes that have changed + minFrom = docSize + maxTo = 0 + + getChangedRanges(tr).forEach(range => { + // Purposefully over scan the range to ensure we catch all decorations + minFrom = Math.min(minFrom, range.newRange.from - 1, range.oldRange.from - 1) + maxTo = Math.max(maxTo, range.newRange.to + 1, range.oldRange.to + 1) + }) + } + + if (tr.selectionSet) { + const { $from, $to } = oldState.selection + const { $from: $newFrom, $to: $newTo } = newState.selection + + // When the selection changes, run on all the nodes between the old and new selection + minFrom = Math.min( + // Purposefully over scan the range to ensure we catch all decorations + $from.depth === 0 ? 0 : $from.before(), + $newFrom.depth === 0 ? 0 : $newFrom.before(), + ) + maxTo = Math.max($to.depth === 0 ? maxTo : $to.after(), $newTo.depth === 0 ? maxTo : $newTo.after()) + } + + return { + minFrom: Math.max(minFrom, 0), + maxTo: Math.min(maxTo, docSize), + } + } + + static resolvePositionsAndType(options: AttachToOptions): ResolvedDecorationOptions { + let offsetFrom = 0 + let offsetTo = 0 + + if (options.offset) { + if (typeof options.offset === 'number') { + offsetFrom = options.offset + offsetTo = options.offset + } else { + offsetFrom = options.offset.start + offsetTo = options.offset.end + } + } + + if (typeof options.target === 'number') { + return { + type: options.type ?? 'widget', + from: options.target + offsetFrom, + to: options.target + offsetTo, + } + } + + if (options.target instanceof Selection) { + return { + type: options.type ?? 'inline', + from: options.target.from + offsetFrom, + to: options.target.to + offsetFrom, + } + } + + if (options.target instanceof NodePos) { + return { + type: options.type ?? (offsetFrom !== 0 || offsetTo !== 0 ? 'inline' : 'node'), + from: options.target.pos + offsetFrom, + to: options.target.pos + options.target.node.nodeSize + offsetTo, + } + } + + if ('from' in options.target && 'to' in options.target) { + return { + type: options.type ?? 'inline', + from: options.target.from + offsetFrom, + to: options.target.to + offsetTo, + } + } + + throw new Error(`Invalid target: ${options.target}`, { cause: options.target }) + } + + attachTo(options: AttachToOptions): void { + const decoMeta = DecorationManager.resolvePositionsAndType(options) + const id = Math.random().toString(36).slice(2, 10) + + switch (decoMeta.type) { + case 'node': { + this.decorations.set(id, { + decoration: Decoration.node(decoMeta.from, decoMeta.to, { class: 'decoration' }, { decorationId: id }), + options, + }) + break + } + case 'inline': { + this.decorations.set(id, { + decoration: Decoration.inline(decoMeta.from, decoMeta.to, { class: 'decoration' }, { decorationId: id }), + options, + }) + break + } + case 'widget': { + this.decorations.set(id, { + decoration: Decoration.widget(decoMeta.from, document.createElement('span'), { + decorationId: id, + }), + options, + }) + break + } + default: + throw new Error(`Invalid type: ${decoMeta.type}`) + } + } + + create( + decorationExtension: TiptapDecoration, + options: { + /** + * The id of the decoration. If not provided, a random id will be generated. + */ + id?: string + /** + * Whether the decoration should be rendered initially. + * @default true + */ + shouldRender?: boolean + } & Omit, + ): () => void { + const instanceId = options.id ?? Math.random().toString(36).slice(2, 10) + const context = { + name: decorationExtension.name, + options: decorationExtension.options, + storage: decorationExtension.storage, + editor: this.editor, + } + + const attributes = + callOrReturn( + getExtensionField(decorationExtension, 'addAttributes', context), + ) || {} + + const spec = { + decoration: { + instanceId, + extension: decorationExtension, + name: decorationExtension.name, + }, + ...callOrReturn(getExtensionField(decorationExtension, 'addSpec', context)), + } + + const decoMeta = DecorationManager.resolvePositionsAndType({ + ...options, + type: getExtensionField(decorationExtension, 'decorationType'), + }) + + switch (decorationExtension.decorationType) { + case 'inline': { + const decoration = Decoration.inline(decoMeta.from, decoMeta.to, attributes, spec) + + this.decorations.set(instanceId, { + decoration, + options, + }) + + break + } + case 'node': { + const decoration = Decoration.node(decoMeta.from, decoMeta.to, attributes, spec) + + this.decorations.set(instanceId, { + decoration, + options, + }) + + break + } + case 'widget': { + const renderMethod = getExtensionField(decorationExtension, 'render', context) + + const decoration = Decoration.widget( + decoMeta.from, + (_v, getPos) => { + return renderMethod({ editor: this.editor, getPos }) + }, + spec, + ) + + this.decorations.set(instanceId, { + decoration, + options, + }) + + break + } + + default: + throw new Error(`Invalid type: ${decoMeta.type}`) + } + + if (options.shouldRender ?? true) { + this.render() + } + + return () => { + this.removeDecoration(instanceId) + } + } + + render(): void { + this.editor.commands.setMeta('updateDecorations', true) + } + + /** + * Remove a decoration by its id. + */ + private removeDecoration(id: string): void { + const decoration = this.decorations.get(id) + if (decoration) { + this.decorations.delete(id) + console.log('removing', decoration) + this.editor.emit('decorationDelete', { + editor: this.editor, + id, + decoration: decoration.decoration.spec.decoration, + options: decoration.options, + }) + } + } +} diff --git a/packages/core/src/Editor.ts b/packages/core/src/Editor.ts index e385e50eae..054a6281f0 100644 --- a/packages/core/src/Editor.ts +++ b/packages/core/src/Editor.ts @@ -4,11 +4,13 @@ import { EditorState, Plugin, PluginKey, Transaction } from '@tiptap/pm/state' import { EditorView } from '@tiptap/pm/view' import { CommandManager } from './CommandManager.js' +import { DecorationManager } from './DecorationManager.js' import { EventEmitter } from './EventEmitter.js' import { ExtensionManager } from './ExtensionManager.js' import { ClipboardTextSerializer, Commands, + DecorationManager as DecorationManagerExtension, Drop, Editable, FocusEvents, @@ -52,6 +54,8 @@ export class Editor extends EventEmitter { public extensionManager!: ExtensionManager + public decorationManager!: DecorationManager + private css!: HTMLStyleElement public schema!: Schema @@ -105,6 +109,7 @@ export class Editor extends EventEmitter { constructor(options: Partial = {}) { super() this.setOptions(options) + this.createDecorationManager() this.createExtensionManager() this.createCommandManager() this.createSchema() @@ -297,6 +302,7 @@ export class Editor extends EventEmitter { Tabindex, Drop, Paste, + DecorationManagerExtension, ].filter(ext => { if (typeof this.options.enableCoreExtensions === 'object') { return ( @@ -313,6 +319,12 @@ export class Editor extends EventEmitter { this.extensionManager = new ExtensionManager(allExtensions, this) } + private createDecorationManager(): void { + this.decorationManager = new DecorationManager({ + editor: this, + }) + } + /** * Creates an command manager. */ diff --git a/packages/core/src/extensions/decorationManager.ts b/packages/core/src/extensions/decorationManager.ts new file mode 100644 index 0000000000..ac9e61c783 --- /dev/null +++ b/packages/core/src/extensions/decorationManager.ts @@ -0,0 +1,12 @@ +import { Extension } from '../Extension.js' + +export const DecorationManager = Extension.create({ + name: 'decorationManager', + + addProseMirrorPlugins() { + + return [ + this.editor.decorationManager.plugin, + ] + }, +}) diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index eca0516c7e..122228be3d 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -1,5 +1,6 @@ export { ClipboardTextSerializer } from './clipboardTextSerializer.js' export { Commands } from './commands.js' +export { DecorationManager } from './decorationManager.js' export { Drop } from './drop.js' export { Editable } from './editable.js' export { FocusEvents } from './focusEvents.js' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ccfa709259..780082281b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export * from './CommandManager.js' +export * from './Decoration.js' export * from './Editor.js' export * from './Extension.js' export * as extensions from './extensions/index.js' @@ -21,6 +22,18 @@ export interface Commands {} // eslint-disable-next-line export interface ExtensionConfig {} +// eslint-disable-next-line +export interface DecorationConfig {} + +// eslint-disable-next-line +export interface WidgetDecorationConfig {} + +// eslint-disable-next-line +export interface InlineDecorationConfig {} + +// eslint-disable-next-line +export interface NodeDecorationConfig {} + // eslint-disable-next-line export interface NodeConfig {} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 690f8e69c7..59accb4a79 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2,7 +2,7 @@ import { Mark as ProseMirrorMark, Node as ProseMirrorNode, ParseOptions, Slice } import { EditorState, Transaction } from '@tiptap/pm/state' import { Mappable } from '@tiptap/pm/transform' import { - Decoration, + Decoration as PMDecoration, DecorationAttrs, EditorProps, EditorView, @@ -11,14 +11,16 @@ import { ViewMutationRecord, } from '@tiptap/pm/view' +import type { Decoration } from './Decoration.js' +import type { AttachToOptions } from './DecorationManager.js' import { Editor } from './Editor.js' import { Extension } from './Extension.js' -import { Commands, ExtensionConfig, MarkConfig, NodeConfig } from './index.js' +import { Commands, DecorationConfig, ExtensionConfig, MarkConfig, NodeConfig } from './index.js' import { Mark } from './Mark.js' import { Node } from './Node.js' -export type AnyConfig = ExtensionConfig | NodeConfig | MarkConfig -export type AnyExtension = Extension | Node | Mark +export type AnyConfig = ExtensionConfig | NodeConfig | MarkConfig | DecorationConfig +export type AnyExtension = Extension | Node | Mark | Decoration export type Extensions = AnyExtension[] export type ParentConfig = Partial<{ @@ -57,6 +59,7 @@ export interface EditorEvents { destroy: void paste: { editor: Editor; event: ClipboardEvent; slice: Slice } drop: { editor: Editor; event: DragEvent; slice: Slice; moved: boolean } + decorationDelete: { editor: Editor; decoration: Decoration; id: string; options: AttachToOptions } } export type EnableRules = (AnyExtension | string)[] | boolean @@ -276,8 +279,8 @@ export type DOMNode = InstanceType */ export interface DecorationType { spec: any - map(mapping: Mappable, span: Decoration, offset: number, oldOffset: number): Decoration | null - valid(node: Node, span: Decoration): boolean + map(mapping: Mappable, span: PMDecoration, offset: number, oldOffset: number): PMDecoration | null + valid(node: Node, span: PMDecoration): boolean eq(other: DecorationType): boolean destroy(dom: DOMNode): void readonly attrs: DecorationAttrs @@ -287,7 +290,7 @@ export interface DecorationType { * prosemirror-view does not export the `type` property of `Decoration`. * This adds the `type` property to the `Decoration` type. */ -export type DecorationWithType = Decoration & { +export type DecorationWithType = PMDecoration & { type: DecorationType }