feat(core): beginnings of a decorations API

This commit is contained in:
Nick the Sick 2024-10-08 15:07:47 +02:00
parent f7453a3292
commit 36489ba873
No known key found for this signature in database
GPG Key ID: F575992F156E5BCC
4 changed files with 724 additions and 7 deletions

View File

@ -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<Options = any, Storage = any, Attrs = any, Spec = any> {
// @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<ParentConfig<DecorationConfig<Options, Storage>>['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<ParentConfig<DecorationConfig<Options, Storage>>['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<DecorationConfig<Options, Storage>>['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<DecorationConfig<Options, Storage>>['addCommands'];
}) => Partial<RawCommands>;
/**
* 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<DecorationConfig<Options, Storage>>['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<DecorationConfig<Options, Storage>>['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<DecorationConfig<Options, Storage>>['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<DecorationConfig<Options, Storage>>['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<DecorationConfig<Options, Storage>>['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<DecorationConfig<Options, Storage>>['extendNodeSchema'];
},
extension: Node
) => Record<string, any>)
| 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<DecorationConfig<Options, Storage>>['extendMarkSchema'];
},
extension: Mark
) => Record<string, any>)
| 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<DecorationConfig<Options, Storage>>['extendDecorationSchema'];
},
extension: Decoration
) => Record<string, any>)
| null;
/**
* The editor is not ready yet.
*/
onBeforeCreate?:
| ((this: {
name: string;
options: Options;
storage: Storage;
editor: Editor;
parent: ParentConfig<DecorationConfig<Options, Storage>>['onBeforeCreate'];
}) => void)
| null;
/**
* The editor is ready.
*/
onCreate?:
| ((this: {
name: string;
options: Options;
storage: Storage;
editor: Editor;
parent: ParentConfig<DecorationConfig<Options, Storage>>['onCreate'];
}) => void)
| null;
/**
* The content has changed.
*/
onUpdate?:
| ((this: {
name: string;
options: Options;
storage: Storage;
editor: Editor;
parent: ParentConfig<DecorationConfig<Options, Storage>>['onUpdate'];
}) => void)
| null;
/**
* The selection has changed.
*/
onSelectionUpdate?:
| ((this: {
name: string;
options: Options;
storage: Storage;
editor: Editor;
parent: ParentConfig<DecorationConfig<Options, Storage>>['onSelectionUpdate'];
}) => void)
| null;
/**
* The editor state has changed.
*/
onTransaction?:
| ((
this: {
name: string;
options: Options;
storage: Storage;
editor: Editor;
parent: ParentConfig<DecorationConfig<Options, Storage>>['onTransaction'];
},
props: {
editor: Editor;
transaction: Transaction;
}
) => void)
| null;
/**
* The editor is focused.
*/
onFocus?:
| ((
this: {
name: string;
options: Options;
storage: Storage;
editor: Editor;
parent: ParentConfig<DecorationConfig<Options, Storage>>['onFocus'];
},
props: {
event: FocusEvent;
}
) => void)
| null;
/**
* The editor isnt focused anymore.
*/
onBlur?:
| ((
this: {
name: string;
options: Options;
storage: Storage;
editor: Editor;
parent: ParentConfig<DecorationConfig<Options, Storage>>['onBlur'];
},
props: {
event: FocusEvent;
}
) => void)
| null;
/**
* The editor is destroyed.
*/
onDestroy?:
| ((this: {
name: string;
options: Options;
storage: Storage;
editor: Editor;
parent: ParentConfig<DecorationConfig<Options, Storage>>['onDestroy'];
}) => void)
| null;
/**
* Add attributes to the node
* @example addAttributes: () => ({ class: 'foo' })
*/
addAttributes?: (this: {
name: string;
options: Options;
storage: Storage;
parent: ParentConfig<DecorationConfig<Options, Storage>>['addAttributes'];
editor?: Editor;
}) => Partial<Attrs>;
/**
* Add attributes to the node
* @example addSpec: () => ({ ctx: 'foo' })
*/
addSpec?: (this: {
name: string;
options: Options;
storage: Storage;
parent: ParentConfig<DecorationConfig<Options, Storage>>['addAttributes'];
editor?: Editor;
}) => Partial<Spec>;
}
}
/**
* The Extension class is the base class for all extensions.
* @see https://tiptap.dev/api/extensions#create-a-new-extension
*/
export class Decoration<Options = any, Storage = any, Attrs = any, Spec = any> {
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<DecorationConfig<Options, Storage, Attrs, Spec>> = {}) {
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<AnyConfig['addOptions']>(this, 'addOptions', {
name: this.name,
}),
)
}
this.storage = callOrReturn(
getExtensionField<AnyConfig['addStorage']>(this, 'addStorage', {
name: this.name,
options: this.options,
}),
) || {}
}
static create<TOptions = any, TStorage = any, TAttrs = any, TSpec = any>(config: Partial<DecorationConfig<TOptions, TStorage, TAttrs, TSpec>> = {}) {
return new Decoration<TOptions, TStorage, TAttrs, TSpec>(config)
}
configure(options: Partial<Options> = {}) {
// return a new instance so we can use the same extension
// with different calls of `configure`
const extension = this.extend<Options, Storage, Attrs, Spec>({
...this.config,
addOptions: () => {
return mergeDeep(this.options as Record<string, any>, 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<ExtendedOptions = Options, ExtendedStorage = Storage, ExtendedAttrs = Attrs, ExtendSpec = Spec>(
extendedConfig: Partial<DecorationConfig<ExtendedOptions, ExtendedStorage, ExtendedAttrs, ExtendSpec>> = {},
) {
const extension = new Decoration<ExtendedOptions, ExtendedStorage, ExtendedAttrs, ExtendSpec>({
...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<AnyConfig['addOptions']>(extension, 'addOptions', {
name: extension.name,
}),
)
extension.storage = callOrReturn(
getExtensionField<AnyConfig['addStorage']>(extension, 'addStorage', {
name: extension.name,
options: extension.options,
}),
)
return extension
}
}

View File

@ -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<DecorationManagerPluginState>('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<AttachToOptions, 'offset' | 'type'>,
): 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 its 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 cant 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<DecorationManagerPluginState>({
key: DecorationManager.pluginKey,
state: {
init(config, instance) {
return {}
},
apply(tr, value, oldState, newState) {
return {}
},
},
props: {
decorations(state) {
},
},
})
}
}

View File

@ -21,6 +21,9 @@ export interface Commands<ReturnType = any> {}
// eslint-disable-next-line
export interface ExtensionConfig<Options = any, Storage = any> {}
// eslint-disable-next-line
export interface DecorationConfig<Options = any, Storage = any> {}
// eslint-disable-next-line
export interface NodeConfig<Options = any, Storage = any> {}

View File

@ -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<T> = Partial<{
@ -233,8 +234,8 @@ export type DOMNode = InstanceType<typeof window.Node>
*/
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;
};