This commit is contained in:
bdbch 2025-05-26 16:51:47 +02:00 committed by GitHub
commit 2066f5de5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 514 additions and 2 deletions

View File

@ -0,0 +1,52 @@
import { Extension, WidgetRenderer } from '@tiptap/react'
import { findWordPositions } from '../findWordPositions.js'
import { DynamicWidget as DynamicWidgetComponent } from './DynamicWidget.jsx'
export const DynamicWidget = Extension.create({
name: 'dynamicWidget',
decorations: ({ editor }) => {
let positions = []
return {
create({ state }) {
positions = findWordPositions('fancy', state)
const decorationItems = []
positions.forEach(pos => {
decorationItems.push({
from: pos.from,
to: pos.from,
type: 'widget',
widget: () => {
return WidgetRenderer.create(DynamicWidgetComponent, {
editor,
as: 'span',
pos,
})
},
})
})
return decorationItems
},
requiresUpdate: ({ newState }) => {
const newPositions = findWordPositions('fancy', newState)
if (newPositions.length !== positions.length) {
return true
}
for (let i = 0; i < newPositions.length; i += 1) {
if (newPositions[i].from !== positions[i].from || newPositions[i].to !== positions[i].to) {
return true
}
}
return false
},
}
},
})

View File

@ -0,0 +1,19 @@
import { useCallback,useState } from 'react'
export const DynamicWidget = props => {
console.log('render')
const { editor, pos } = props
const [count, setCount] = useState(0)
const onRemove = useCallback(() => {
editor.chain().deleteRange({ from: pos.from, to: pos.to }).focus(pos.from).run()
}, [editor, pos])
return (
<>
<span>Count: {count}</span>
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>-</button>
<button onClick={onRemove}>Remove</button>
</>
)
}

View File

@ -0,0 +1,39 @@
import { Extension } from '@tiptap/react'
export const SelectionDecorations = Extension.create({
name: 'selectionDecoration',
decorations: () => {
return {
create({ state }) {
const nodeSelection = {
from: state.selection.$from.start(1) - 1,
to: state.selection.$to.end(1) + 1,
}
const nodeDecoration = {
from: Math.max(0, nodeSelection.from),
to: Math.min(state.doc.nodeSize, nodeSelection.to),
type: 'node',
attributes: { class: 'selected-node' },
}
const textDecoration = {
from: Math.max(0, state.selection.from),
to: Math.min(state.selection.to),
type: 'inline',
attributes: { class: 'selected-text' },
}
if (state.selection.empty) {
return [nodeDecoration]
}
return [nodeDecoration, textDecoration]
},
requiresUpdate: ({ tr }) => {
return tr.selectionSet || tr.docChanged
},
}
},
})

View File

@ -0,0 +1,48 @@
import { Extension } from '@tiptap/react'
import { findWordPositions } from '../findWordPositions.js'
export const StarDecoration = Extension.create({
name: 'exampleDecorations',
decorations: () => {
let positions = []
return {
create({ state }) {
positions = findWordPositions('fancy', state)
const decorationItems = []
positions.forEach(pos => {
decorationItems.push({
from: pos.to,
to: pos.to,
type: 'widget',
widget: () => {
const el = document.createElement('span')
el.textContent = '⭐️'
return el
},
})
})
return decorationItems
},
equiresUpdate: ({ newState }) => {
const newPositions = findWordPositions('fancy', newState)
if (newPositions.length !== positions.length) {
return true
}
for (let i = 0; i < newPositions.length; i += 1) {
if (newPositions[i].from !== positions[i].from || newPositions[i].to !== positions[i].to) {
return true
}
}
return false
},
}
},
})

View File

@ -0,0 +1,24 @@
import './styles.scss'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React from 'react'
import { DynamicWidget } from './DynamicWidget.js'
import { SelectionDecorations } from './SelectionDecoration.js'
import { StarDecoration } from './StarDecoration.js'
export default () => {
const editor = useEditor({
extensions: [StarterKit, StarDecoration, DynamicWidget, SelectionDecorations],
content: `
<p>This is a fancy example for the decorations API.</p>
`,
})
return (
<>
<EditorContent editor={editor} />
</>
)
}

View File

@ -0,0 +1,16 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
.selected-text {
box-shadow: 0 0 0 4px #00ff00ff;
}
.selected-node {
background-color: #00ff0060;
box-shadow: 0 0 0 0.5rem #00ff0060;
border-radius: 0.25rem;
}
}

View File

@ -0,0 +1,16 @@
export function findWordPositions(word, state) {
const positions = []
state.doc.descendants((node, pos) => {
if (node.isText && node.text.toLowerCase().includes(word)) {
// find all occurrences of the word “example”, don't stop after the first
for (let i = 0; i < node.text.length; i += 1) {
if (node.text.toLowerCase().substr(i).startsWith(word.toLowerCase())) {
positions.push({ from: pos + i, to: pos + i + word.length })
}
}
}
})
return positions
}

View File

@ -0,0 +1,136 @@
import { type EditorState, type Transaction, Plugin, PluginKey } from '@tiptap/pm/state'
import { type EditorView, Decoration, DecorationSet } from '@tiptap/pm/view'
import type { Editor } from './Editor'
import type { Extension } from './Extension'
import type { DecorationItemWithExtension, DecorationOptions } from './types'
import { findDecoUpdates } from './utilities/decorations.js'
export const decorationPluginKey = new PluginKey('__tiptap_decorations')
export class DecorationManager {
editor: Editor
extensions: Extension[]
decorationConfigs: Record<string, DecorationOptions> = {}
constructor(props: { editor: Editor; extensions: Extension[] }) {
this.extensions = props.extensions
this.editor = props.editor
this.extensions.forEach(ext => {
if (ext.config.decorations) {
this.decorationConfigs[ext.name] = ext.config.decorations({ editor: this.editor })
}
})
}
createPlugin() {
return new Plugin({
key: decorationPluginKey,
state: {
init: () => {
return this.createDecorations(this.editor.state, this.editor.view)
},
apply: (tr, decorationSet, oldState, newState) => {
if (!this.needsRecreation(tr, oldState, newState)) {
return decorationSet.map(tr.mapping, tr.doc)
}
const newDecorations = this.createDecorations(newState, this.editor.view)
return findDecoUpdates(tr, decorationSet, newDecorations)
},
},
props: {
decorations(state) {
return this.getState(state)
// return this.createDecorations(state, this.editor.view)
},
},
})
}
createDecorations(state: EditorState, view: EditorView) {
let items: DecorationItemWithExtension[] = []
const extNames = Object.keys(this.decorationConfigs)
extNames.forEach(name => {
const config = this.decorationConfigs[name]
if (!config.create) {
return
}
const decos = config
.create({
state,
view,
editor: this.editor,
})
?.map(item => ({
...item,
extension: name,
}))
if (!decos) {
return
}
items = items ? [...items, ...decos] : decos
})
const decorations = items.map(item => {
switch (item.type) {
case 'node':
return Decoration.node(item.from, item.to, item.attributes || {}, {
extension: item.extension,
})
case 'inline':
return Decoration.inline(item.from, item.to, item.attributes || {}, {
extension: item.extension,
})
case 'widget':
if (!item.widget) {
throw new Error('Widget decoration requires a widget property')
}
return Decoration.widget(item.from, item.widget, {
extension: item.extension,
})
default:
throw new Error(`Unknown decoration type: ${item.type}`)
}
})
return DecorationSet.create(state.doc, decorations)
}
needsRecreation(tr: Transaction, oldState: EditorState, newState: EditorState) {
let needsRecreation = false
const names = Object.keys(this.decorationConfigs)
names.forEach(name => {
const config = this.decorationConfigs[name]
if (
config.requiresUpdate &&
config.requiresUpdate({
tr,
oldState,
newState,
})
) {
needsRecreation = true
}
})
return needsRecreation
}
}

View File

@ -5,6 +5,7 @@ import { EditorState } 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 {
@ -53,6 +54,8 @@ export interface TiptapEditorHTMLElement extends HTMLElement {
export class Editor extends EventEmitter<EditorEvents> {
private commandManager!: CommandManager
private decorationManager!: DecorationManager
public extensionManager!: ExtensionManager
private css: HTMLStyleElement | null = null
@ -113,6 +116,7 @@ export class Editor extends EventEmitter<EditorEvents> {
this.setOptions(options)
this.createExtensionManager()
this.createCommandManager()
this.createDecorationManager()
this.createSchema()
this.on('beforeCreate', this.options.onBeforeCreate)
this.emit('beforeCreate', { editor: this })
@ -421,6 +425,13 @@ export class Editor extends EventEmitter<EditorEvents> {
})
}
private createDecorationManager(): void {
this.decorationManager = new DecorationManager({
editor: this,
extensions: this.extensionManager.extensions,
})
}
/**
* Creates a ProseMirror schema.
*/
@ -491,7 +502,7 @@ export class Editor extends EventEmitter<EditorEvents> {
// `editor.view` is not yet available at this time.
// Therefore we will add all plugins and node views directly afterwards.
const newState = this.state.reconfigure({
plugins: this.extensionManager.plugins,
plugins: [this.decorationManager.createPlugin(), ...this.extensionManager.plugins],
})
this.view.updateState(newState)

View File

@ -9,6 +9,7 @@ import type { Node } from './Node.js'
import type { PasteRule } from './PasteRule.js'
import type {
AnyConfig,
DecorationOptions,
EditorEvents,
Extensions,
GlobalAttributes,
@ -224,6 +225,28 @@ export interface ExtendableConfig<
parent: ParentConfig<Config>['addExtensions']
}) => Extensions
/**
* This function allows developers to render decorations in the editor.
* @example
* decorations: {
* create({ state }) {
* return [
* type: 'node', // or `inline` or `mark`
* from: 10,
* to: 20,
* attributes: { class: 'my-decoration' },
* // for widgets
* widget: (view, pos) => {
* const element = document.createElement('span')
* element.textContent = '🎉'
* return element
* }
* ]
* },
* }
*/
decorations?: (props: { editor: Editor }) => DecorationOptions
/**
* This function extends the schema of the node.
* @example

View File

@ -722,3 +722,35 @@ export type ExtendedRegExpMatchArray = RegExpMatchArray & {
}
export type Dispatch = ((args?: any) => any) | undefined
/**
* The options for the Extension Decoration API.
* This is used to create decorations for the editor.
*/
export interface DecorationOptions {
/**
* This function creates the decorations for this extension. It should return an array of decoration items.
* @param props Context related props like the editor state, view, the editor itself
* @returns An array of decoration items or null if no decorations should be created
*/
create?: (props: { state: EditorState; view?: EditorView; editor: Editor }) => DecorationItem[] | null
/**
* This function is called on each transaction to check if the decorations need to be updated or not.
* @param props Context related props like the editor state, view, the editor itself
* @returns true if the decorations need to be updated, false otherwise
*/
requiresUpdate?: (props: { tr: Transaction; oldState: EditorState; newState: EditorState }) => boolean
}
export interface DecorationItem {
type: 'node' | 'inline' | 'widget'
from: number
to: number
attributes?: Record<string, string>
widget?: (view: EditorView, getPos: () => number | undefined) => HTMLElement
}
export interface DecorationItemWithExtension extends DecorationItem {
extension: string
}

View File

@ -0,0 +1,57 @@
import type { Transaction } from '@tiptap/pm/state'
import type { Decoration, DecorationSet } from '@tiptap/pm/view'
/**
* Finds removed and added decorations from one decoration set to the next
* and removes/adds them on the old decoration set - returns the updated old decoration set
* check is done by new mapped position & specs
* @param transaction The transaction to apply
* @param oldDecorationSet The old decoration set
* @param newDecorationSet The new decoration set
*/
export function findDecoUpdates(
tr: Transaction,
oldDecorationSet: DecorationSet,
newDecorationSet: DecorationSet,
): DecorationSet {
// map the old decoration to the updated positions
let mappedOldDecorations = oldDecorationSet.map(tr.mapping, tr.doc)
const oldDecorations = mappedOldDecorations.find()
const newDecorations = newDecorationSet.find()
const decosToRemove: Decoration[] = []
const decosToAdd: Decoration[] = []
// lets find decorations on the old decorations that are not in the new decorations
oldDecorations.forEach(oldDeco => {
const isSame = newDecorations.some(newDeco => {
return oldDeco.from === newDeco.from && oldDeco.to === newDeco.to && oldDeco.spec?.name === newDeco.spec?.name
})
if (!isSame) {
decosToRemove.push(oldDeco)
}
})
// lets find new decorations that are not in the old decorations
newDecorations.forEach(newDeco => {
const isSame = oldDecorations.some(oldDeco => {
return oldDeco.from === newDeco.from && oldDeco.to === newDeco.to && oldDeco.spec?.name === newDeco.spec?.name
})
if (!isSame) {
decosToAdd.push(newDeco)
}
})
if (decosToRemove.length > 0) {
mappedOldDecorations = mappedOldDecorations.remove(decosToRemove)
}
if (decosToAdd.length > 0) {
mappedOldDecorations = mappedOldDecorations.add(tr.doc, decosToAdd)
}
return mappedOldDecorations
}

View File

@ -52,7 +52,7 @@ export interface ReactRendererOptions {
className?: string
}
type ComponentType<R, P> =
export type ComponentType<R, P> =
| React.ComponentClass<P>
| React.FunctionComponent<P>
| React.ForwardRefExoticComponent<React.PropsWithoutRef<P> & React.RefAttributes<R>>

View File

@ -0,0 +1,38 @@
import type { EditorWithContentComponent as Editor } from './Editor.js'
import { type ComponentType, ReactRenderer } from './ReactRenderer.js'
export type WidgetRendererOptions<P extends Record<string, any> = object> = {
editor: Editor
pos: { from: number; to: number }
as?: string
props?: P
}
export class WidgetRenderer {
static create<R = unknown, P extends Record<string, any> = object>(
component: ComponentType<R, P>,
options: WidgetRendererOptions<P>,
) {
const { editor, pos, as = 'span', props } = options
const reactView = new ReactRenderer(component, {
editor,
as,
props: {
...props,
editor,
pos,
},
})
// wait for the next frame to render the component
// otherwise the component will not be rendered correctly or will not have all events attached
requestAnimationFrame(() => {
reactView.render()
})
return reactView.element
}
}
export default WidgetRenderer

View File

@ -8,4 +8,5 @@ export * from './ReactRenderer.js'
export * from './useEditor.js'
export * from './useEditorState.js'
export * from './useReactNodeView.js'
export * from './WidgetRenderer.js'
export * from '@tiptap/core'