From 36489ba8733b3b8dd48aa682ab94b5855c955160 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 8 Oct 2024 15:07:47 +0200 Subject: [PATCH] feat(core): beginnings of a decorations API --- packages/core/src/Decoration.ts | 557 +++++++++++++++++++++++++ packages/core/src/DecorationManager.ts | 156 +++++++ packages/core/src/index.ts | 3 + packages/core/src/types.ts | 15 +- 4 files changed, 724 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/Decoration.ts create mode 100644 packages/core/src/DecorationManager.ts diff --git a/packages/core/src/Decoration.ts b/packages/core/src/Decoration.ts new file mode 100644 index 00000000000..6190f83fef8 --- /dev/null +++ b/packages/core/src/Decoration.ts @@ -0,0 +1,557 @@ +import { Plugin, Transaction } from '@tiptap/pm/state' + +import { Editor } from './Editor.js' +import { getExtensionField } from './helpers/getExtensionField.js' +import { DecorationConfig } 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; + + /** + * The default options for this extension. + * @example + * defaultOptions: { + * myOption: 'foo', + * myOtherOption: 10, + * } + */ + defaultOptions?: Options; + + /** + * 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; + + /** + * This function extends the schema of the node. + * @example + * extendNodeSchema() { + * return { + * group: 'inline', + * selectable: false, + * } + * } + */ + extendNodeSchema?: + | (( + this: { + name: string; + options: Options; + storage: Storage; + parent: ParentConfig>['extendNodeSchema']; + }, + extension: Node + ) => Record) + | null; + + /** + * This function extends the schema of the mark. + * @example + * extendMarkSchema() { + * return { + * group: 'inline', + * selectable: false, + * } + * } + */ + extendMarkSchema?: + | (( + this: { + name: string; + options: Options; + storage: Storage; + parent: ParentConfig>['extendMarkSchema']; + }, + extension: Mark + ) => Record) + | null; + + /** + * This function extends the schema of the decoration. + * @example + * extendMarkSchema() { + * return { + * group: 'inline', + * selectable: false, + * } + * } + */ + extendDecorationSchema?: + | (( + this: { + name: string; + options: Options; + storage: Storage; + parent: ParentConfig>['extendDecorationSchema']; + }, + extension: Decoration + ) => Record) + | null; + + /** + * 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; + + /** + * Add attributes to the node + * @example addAttributes: () => ({ class: 'foo' }) + */ + addAttributes?: (this: { + name: string; + options: Options; + storage: Storage; + parent: ParentConfig>['addAttributes']; + editor?: Editor; + }) => Partial; + + /** + * Add attributes to the node + * @example addSpec: () => ({ ctx: 'foo' }) + */ + addSpec?: (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' + + parent: Decoration | null = null + + child: Decoration | null = null + + options: Options + + storage: Storage + + config: DecorationConfig = { + name: this.name, + defaultOptions: {}, + } + + constructor(config: Partial> = {}) { + this.config = { + ...this.config, + ...config, + } + + this.name = this.config.name + + if (config.defaultOptions && Object.keys(config.defaultOptions).length > 0) { + console.warn( + `[tiptap warn]: BREAKING CHANGE: "defaultOptions" is deprecated. Please use "addOptions" instead. Found in extension: "${this.name}".`, + ) + } + + // TODO: remove `addOptions` fallback + this.options = this.config.defaultOptions + + 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 + + if (extendedConfig.defaultOptions && Object.keys(extendedConfig.defaultOptions).length > 0) { + console.warn( + `[tiptap warn]: BREAKING CHANGE: "defaultOptions" is deprecated. Please use "addOptions" instead. Found in extension: "${extension.name}".`, + ) + } + + extension.options = callOrReturn( + getExtensionField(extension, 'addOptions', { + name: extension.name, + }), + ) + + extension.storage = callOrReturn( + getExtensionField(extension, 'addStorage', { + name: extension.name, + options: extension.options, + }), + ) + + return extension + } +} diff --git a/packages/core/src/DecorationManager.ts b/packages/core/src/DecorationManager.ts new file mode 100644 index 00000000000..f3af54f088b --- /dev/null +++ b/packages/core/src/DecorationManager.ts @@ -0,0 +1,156 @@ +import { Node } from '@tiptap/pm/model' +import { Plugin, PluginKey, Selection } from '@tiptap/pm/state' +import { DecorationSet, DecorationSource } from '@tiptap/pm/view' + +import { Editor } from './Editor.js' +import { NodePos } from './NodePos.js' +import { Range } from './types.js' + +type AttachToOptions = { + /** + * 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 = {}; +const pluginKey = new PluginKey('tiptapDecorationManager') + +export class DecorationManager { + editor: Editor + + decorations: DecorationSource + + constructor(props: { editor: Editor }) { + this.editor = props.editor + this.decorations = DecorationSet.empty + } + + private resolvePositionsAndType( + target: Node | NodePos | Range | Selection | number, + options?: Pick, + ): 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 target === 'number') { + return { + type: options?.type ?? 'widget', + from: target + offsetFrom, + to: target + offsetTo, + } + } + + if (target instanceof Selection) { + return { + type: options?.type ?? 'inline', + from: target.from + offsetFrom, + to: target.to + offsetFrom, + } + } + + if (target instanceof NodePos) { + return { + type: options?.type ?? (offsetFrom !== 0 || offsetTo !== 0 ? 'inline' : 'node'), + from: target.pos + offsetFrom, + to: target.pos + target.node.nodeSize + offsetTo, + } + } + + if ('from' in target && 'to' in target) { + return { + type: options?.type ?? 'inline', + from: target.from + offsetFrom, + to: target.to + offsetTo, + } + } + + if (target instanceof Node) { + // This may be too expensive for large documents, but it’s the only way to find the correct position + let from = -1 + let to = -1 + + // Perhaps we can find the node faster than doing a full traversal + this.editor.state.doc.descendants((node, pos) => { + if (from !== -1 && to !== -1) { + return false + } + if (node === target) { + from = pos + to = pos + node.nodeSize + } + }) + + if (from === -1 || to === -1) { + throw new Error('Node not found', { cause: target }) + } + + return { + // a node can’t have an offset, so we need to convert it to an inline decoration + type: options?.type ?? (offsetFrom !== 0 || offsetTo !== 0 ? 'inline' : 'node'), + from, + to, + } + } + + throw new Error(`Invalid target: ${target}`, { cause: target }) + } + + attachTo(targetNode: Node, options?: AttachToOptions): void; + attachTo(targetNode: NodePos, options?: AttachToOptions): void; + attachTo(range: Range, options?: AttachToOptions): void; + attachTo(selection: Selection, options?: AttachToOptions): void; + attachTo(pos: number, options?: AttachToOptions): void; + attachTo(target: Node | NodePos | Range | Selection | number, options?: AttachToOptions): void { + this.resolvePositionsAndType(target, options) + } + + static get pluginKey() { + return pluginKey + } + + getPlugin() { + return new Plugin({ + key: DecorationManager.pluginKey, + state: { + init(config, instance) { + return {} + }, + apply(tr, value, oldState, newState) { + return {} + }, + }, + props: { + decorations(state) { + + }, + }, + + }) + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ccfa709259c..b03d347960c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,6 +21,9 @@ export interface Commands {} // eslint-disable-next-line export interface ExtensionConfig {} +// eslint-disable-next-line +export interface DecorationConfig {} + // eslint-disable-next-line export interface NodeConfig {} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 23ae741a458..d33c0432909 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -7,7 +7,7 @@ import { import { EditorState, Transaction } from '@tiptap/pm/state' import { Mappable } from '@tiptap/pm/transform' import { - Decoration, + Decoration as PMDecoration, DecorationAttrs, EditorProps, EditorView, @@ -15,16 +15,17 @@ import { NodeViewConstructor, } from '@tiptap/pm/view' +import { Decoration } from './Decoration.js' import { Editor } from './Editor.js' import { Extension } from './Extension.js' import { - Commands, ExtensionConfig, MarkConfig, NodeConfig, + 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<{ @@ -233,8 +234,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 @@ -244,7 +245,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; };