mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-07 17:43:49 +08:00
Merge 2dfabbdba8
into 4f498944b5
This commit is contained in:
commit
2066f5de5a
52
demos/src/Experiments/DecorationsAPI/React/DynamicWidget.js
Normal file
52
demos/src/Experiments/DecorationsAPI/React/DynamicWidget.js
Normal 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
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
19
demos/src/Experiments/DecorationsAPI/React/DynamicWidget.jsx
Normal file
19
demos/src/Experiments/DecorationsAPI/React/DynamicWidget.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
48
demos/src/Experiments/DecorationsAPI/React/StarDecoration.js
Normal file
48
demos/src/Experiments/DecorationsAPI/React/StarDecoration.js
Normal 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
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
24
demos/src/Experiments/DecorationsAPI/React/index.jsx
Normal file
24
demos/src/Experiments/DecorationsAPI/React/index.jsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
16
demos/src/Experiments/DecorationsAPI/React/styles.scss
Normal file
16
demos/src/Experiments/DecorationsAPI/React/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
16
demos/src/Experiments/DecorationsAPI/findWordPositions.js
Normal file
16
demos/src/Experiments/DecorationsAPI/findWordPositions.js
Normal 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
|
||||
}
|
136
packages/core/src/DecorationManager.ts
Normal file
136
packages/core/src/DecorationManager.ts
Normal 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
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
57
packages/core/src/utilities/decorations.ts
Normal file
57
packages/core/src/utilities/decorations.ts
Normal 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
|
||||
}
|
@ -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>>
|
||||
|
38
packages/react/src/WidgetRenderer.ts
Normal file
38
packages/react/src/WidgetRenderer.ts
Normal 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
|
@ -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'
|
||||
|
Loading…
Reference in New Issue
Block a user