refactor(core): update typings for NodeViews to be accurate to implementation #5483 (#5526)

This commit is contained in:
Nick Perez 2024-08-20 16:25:16 +02:00 committed by GitHub
parent 04bf24aed0
commit 7f24a6677b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 412 additions and 127 deletions

View File

@ -0,0 +1,8 @@
---
"@tiptap/react": patch
"@tiptap/vue-2": patch
"@tiptap/vue-3": patch
"@tiptap/core": patch
---
Update the Typescript types for NodeViews, bringing them inline with there actual implementation

View File

@ -0,0 +1,6 @@
---
"@tiptap/vue-2": minor
"@tiptap/vue-3": minor
---
Vue NodeViews now listen for changes to selections and re-render when the selection is actually over the nodeview

View File

@ -1,7 +1,7 @@
import { keymap } from '@tiptap/pm/keymap' import { keymap } from '@tiptap/pm/keymap'
import { Node as ProsemirrorNode, Schema } from '@tiptap/pm/model' import { Schema } from '@tiptap/pm/model'
import { Plugin } from '@tiptap/pm/state' import { Plugin } from '@tiptap/pm/state'
import { Decoration, EditorView } from '@tiptap/pm/view' import { NodeViewConstructor } from '@tiptap/pm/view'
import type { Editor } from './Editor.js' import type { Editor } from './Editor.js'
import { getAttributesFromExtensions } from './helpers/getAttributesFromExtensions.js' import { getAttributesFromExtensions } from './helpers/getAttributesFromExtensions.js'
@ -288,21 +288,26 @@ export class ExtensionManager {
return [] return []
} }
const nodeview = ( const nodeview: NodeViewConstructor = (
node: ProsemirrorNode, node,
view: EditorView, view,
getPos: (() => number) | boolean, getPos,
decorations: Decoration[], decorations,
innerDecorations,
) => { ) => {
const HTMLAttributes = getRenderedAttributes(node, extensionAttributes) const HTMLAttributes = getRenderedAttributes(node, extensionAttributes)
return addNodeView()({ return addNodeView()({
editor, // pass-through
node, node,
getPos, view,
getPos: getPos as () => number,
decorations, decorations,
HTMLAttributes, innerDecorations,
// tiptap-specific
editor,
extension, extension,
HTMLAttributes,
}) })
} }

View File

@ -1,9 +1,7 @@
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { NodeSelection } from '@tiptap/pm/state' import { NodeSelection } from '@tiptap/pm/state'
import { NodeView as ProseMirrorNodeView } from '@tiptap/pm/view' import { NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
import { Editor as CoreEditor } from './Editor.js' import { Editor as CoreEditor } from './Editor.js'
import { Node } from './Node.js'
import { DecorationWithType, NodeViewRendererOptions, NodeViewRendererProps } from './types.js' import { DecorationWithType, NodeViewRendererOptions, NodeViewRendererProps } from './types.js'
import { isAndroid } from './utilities/isAndroid.js' import { isAndroid } from './utilities/isAndroid.js'
import { isiOS } from './utilities/isiOS.js' import { isiOS } from './utilities/isiOS.js'
@ -23,13 +21,19 @@ export class NodeView<
options: Options options: Options
extension: Node extension: NodeViewRendererProps['extension']
node: ProseMirrorNode node: NodeViewRendererProps['node']
decorations: DecorationWithType[] decorations: NodeViewRendererProps['decorations']
getPos: any innerDecorations: NodeViewRendererProps['innerDecorations']
view: NodeViewRendererProps['view']
getPos: NodeViewRendererProps['getPos']
HTMLAttributes: NodeViewRendererProps['HTMLAttributes']
isDragging = false isDragging = false
@ -44,6 +48,9 @@ export class NodeView<
this.extension = props.extension this.extension = props.extension
this.node = props.node this.node = props.node
this.decorations = props.decorations as DecorationWithType[] this.decorations = props.decorations as DecorationWithType[]
this.innerDecorations = props.innerDecorations
this.view = props.view
this.HTMLAttributes = props.HTMLAttributes
this.getPos = props.getPos this.getPos = props.getPos
this.mount() this.mount()
} }
@ -93,9 +100,14 @@ export class NodeView<
event.dataTransfer?.setDragImage(this.dom, x, y) event.dataTransfer?.setDragImage(this.dom, x, y)
const pos = this.getPos()
if (typeof pos !== 'number') {
return
}
// we need to tell ProseMirror that we want to move the whole node // we need to tell ProseMirror that we want to move the whole node
// so we create a NodeSelection // so we create a NodeSelection
const selection = NodeSelection.create(view.state.doc, this.getPos()) const selection = NodeSelection.create(view.state.doc, pos)
const transaction = view.state.tr.setSelection(selection) const transaction = view.state.tr.setSelection(selection)
view.dispatch(transaction) view.dispatch(transaction)
@ -197,6 +209,11 @@ export class NodeView<
return true return true
} }
/**
* Called when a DOM [mutation](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) or a selection change happens within the view.
* @return `false` if the editor should re-read the selection or re-parse the range around the mutation
* @return `true` if it can safely be ignored.
*/
ignoreMutation(mutation: MutationRecord | { type: 'selection'; target: Element }) { ignoreMutation(mutation: MutationRecord | { type: 'selection'; target: Element }) {
if (!this.dom || !this.contentDOM) { if (!this.dom || !this.contentDOM) {
return true return true
@ -254,10 +271,17 @@ export class NodeView<
return true return true
} }
updateAttributes(attributes: {}) { /**
* Update the attributes of the prosemirror node.
*/
updateAttributes(attributes: Record<string, any>): void {
this.editor.commands.command(({ tr }) => { this.editor.commands.command(({ tr }) => {
const pos = this.getPos() const pos = this.getPos()
if (typeof pos !== 'number') {
return false
}
tr.setNodeMarkup(pos, undefined, { tr.setNodeMarkup(pos, undefined, {
...this.node.attrs, ...this.node.attrs,
...attributes, ...attributes,
@ -267,8 +291,15 @@ export class NodeView<
}) })
} }
/**
* Delete the node.
*/
deleteNode(): void { deleteNode(): void {
const from = this.getPos() const from = this.getPos()
if (typeof from !== 'number') {
return
}
const to = from + this.node.nodeSize const to = from + this.node.nodeSize
this.editor.commands.deleteRange({ from, to }) this.editor.commands.deleteRange({ from, to })

View File

@ -6,7 +6,11 @@ import {
} from '@tiptap/pm/model' } from '@tiptap/pm/model'
import { EditorState, Transaction } from '@tiptap/pm/state' import { EditorState, Transaction } from '@tiptap/pm/state'
import { import {
Decoration, EditorProps, EditorView, NodeView, Decoration,
EditorProps,
EditorView,
NodeView,
NodeViewConstructor,
} from '@tiptap/pm/view' } from '@tiptap/pm/view'
import { Editor } from './Editor.js' import { Editor } from './Editor.js'
@ -184,20 +188,21 @@ export type ValuesOf<T> = T[keyof T];
export type KeysWithTypeOf<T, Type> = { [P in keyof T]: T[P] extends Type ? P : never }[keyof T]; export type KeysWithTypeOf<T, Type> = { [P in keyof T]: T[P] extends Type ? P : never }[keyof T];
export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};
export type DecorationWithType = Decoration & { export type DecorationWithType = Decoration & {
type: NodeType; type: NodeType;
}; };
export type NodeViewProps = { export type NodeViewProps = Simplify<
editor: Editor; Omit<NodeViewRendererProps, 'decorations'> & {
node: ProseMirrorNode; // TODO this type is not technically correct, but it's the best we can do for now since prosemirror doesn't expose the type of decorations
decorations: DecorationWithType[]; decorations: readonly DecorationWithType[];
selected: boolean; selected: boolean;
extension: Node; updateAttributes: (attributes: Record<string, any>) => void;
getPos: () => number; deleteNode: () => void;
updateAttributes: (attributes: Record<string, any>) => void; }
deleteNode: () => void; >;
};
export interface NodeViewRendererOptions { export interface NodeViewRendererOptions {
stopEvent: ((props: { event: Event }) => boolean) | null; stopEvent: ((props: { event: Event }) => boolean) | null;
@ -208,15 +213,19 @@ export interface NodeViewRendererOptions {
} }
export type NodeViewRendererProps = { export type NodeViewRendererProps = {
// pass-through from prosemirror
node: Parameters<NodeViewConstructor>[0];
view: Parameters<NodeViewConstructor>[1];
getPos: () => number; // TODO getPos was incorrectly typed before, change to `Parameters<NodeViewConstructor>[2];` in the next major version
decorations: Parameters<NodeViewConstructor>[3];
innerDecorations: Parameters<NodeViewConstructor>[4];
// tiptap-specific
editor: Editor; editor: Editor;
node: ProseMirrorNode;
getPos: (() => number) | boolean;
HTMLAttributes: Record<string, any>;
decorations: Decoration[];
extension: Node; extension: Node;
HTMLAttributes: Record<string, any>;
}; };
export type NodeViewRenderer = (props: NodeViewRendererProps) => NodeView | {}; export type NodeViewRenderer = (props: NodeViewRendererProps) => NodeView;
export type AnyCommands = Record<string, (...args: any[]) => Command>; export type AnyCommands = Record<string, (...args: any[]) => Command>;

View File

@ -154,6 +154,10 @@ export const TaskItem = Node.create<TaskItemOptions>({
.focus(undefined, { scrollIntoView: false }) .focus(undefined, { scrollIntoView: false })
.command(({ tr }) => { .command(({ tr }) => {
const position = getPos() const position = getPos()
if (typeof position !== 'number') {
return false
}
const currentNode = tr.doc.nodeAt(position) const currentNode = tr.doc.nodeAt(position)
tr.setNodeMarkup(position, undefined, { tr.setNodeMarkup(position, undefined, {

View File

@ -6,56 +6,85 @@ import {
NodeViewProps, NodeViewProps,
NodeViewRenderer, NodeViewRenderer,
NodeViewRendererOptions, NodeViewRendererOptions,
NodeViewRendererProps,
} from '@tiptap/core' } from '@tiptap/core'
import { Node as ProseMirrorNode } from '@tiptap/pm/model' import { Node, Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Decoration, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view' import { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
import React from 'react' import React, { ComponentType } from 'react'
import { EditorWithContentComponent } from './Editor.js' import { EditorWithContentComponent } from './Editor.js'
import { ReactRenderer } from './ReactRenderer.js' import { ReactRenderer } from './ReactRenderer.js'
import { ReactNodeViewContext, ReactNodeViewContextProps } from './useReactNodeView.js' import { ReactNodeViewContext, ReactNodeViewContextProps } from './useReactNodeView.js'
export interface ReactNodeViewRendererOptions extends NodeViewRendererOptions { export interface ReactNodeViewRendererOptions extends NodeViewRendererOptions {
/**
* This function is called when the node view is updated.
* It allows you to compare the old node with the new node and decide if the component should update.
*/
update: update:
| ((props: { | ((props: {
oldNode: ProseMirrorNode oldNode: ProseMirrorNode;
oldDecorations: Decoration[] oldDecorations: readonly Decoration[];
newNode: ProseMirrorNode oldInnerDecorations: DecorationSource;
newDecorations: Decoration[] newNode: ProseMirrorNode;
updateProps: () => void newDecorations: readonly Decoration[];
innerDecorations: DecorationSource;
updateProps: () => void;
}) => boolean) }) => boolean)
| null | null;
as?: string /**
className?: string * The tag name of the element wrapping the React component.
*/
as?: string;
/**
* The class name of the element wrapping the React component.
*/
className?: string;
/**
* Attributes that should be applied to the element wrapping the React component.
* If this is a function, it will be called each time the node view is updated.
* If this is an object, it will be applied once when the node view is mounted.
*/
attrs?: attrs?:
| Record<string, string> | Record<string, string>
| ((props: { | ((props: {
node: ProseMirrorNode node: ProseMirrorNode;
HTMLAttributes: Record<string, any> HTMLAttributes: Record<string, any>;
}) => Record<string, string>) }) => Record<string, string>);
} }
class ReactNodeView extends NodeView< export class ReactNodeView<
React.FunctionComponent, Component extends ComponentType<NodeViewProps> = ComponentType<NodeViewProps>,
Editor, NodeEditor extends Editor = Editor,
ReactNodeViewRendererOptions Options extends ReactNodeViewRendererOptions = ReactNodeViewRendererOptions,
> { > extends NodeView<Component, NodeEditor, Options> {
renderer!: ReactRenderer /**
* The renderer instance.
*/
renderer!: ReactRenderer<unknown, NodeViewProps>
/**
* The element that holds the rich-text content of the node.
*/
contentDOMElement!: HTMLElement | null contentDOMElement!: HTMLElement | null
/**
* Setup the React component.
* Called on initialization.
*/
mount() { mount() {
const props: NodeViewProps = { const props = {
editor: this.editor, editor: this.editor,
node: this.node, node: this.node,
decorations: this.decorations, decorations: this.decorations as DecorationWithType[],
innerDecorations: this.innerDecorations,
view: this.view,
selected: false, selected: false,
extension: this.extension, extension: this.extension,
HTMLAttributes: this.HTMLAttributes,
getPos: () => this.getPos(), getPos: () => this.getPos(),
updateAttributes: (attributes = {}) => this.updateAttributes(attributes), updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
deleteNode: () => this.deleteNode(), deleteNode: () => this.deleteNode(),
} } satisfies NodeViewProps
if (!(this.component as any).displayName) { if (!(this.component as any).displayName) {
const capitalizeFirstChar = (string: string): string => { const capitalizeFirstChar = (string: string): string => {
@ -75,13 +104,15 @@ class ReactNodeView extends NodeView<
const Component = this.component const Component = this.component
// For performance reasons, we memoize the provider component // For performance reasons, we memoize the provider component
// And all of the things it requires are declared outside of the component, so it doesn't need to re-render // And all of the things it requires are declared outside of the component, so it doesn't need to re-render
const ReactNodeViewProvider: React.FunctionComponent = React.memo(componentProps => { const ReactNodeViewProvider: React.FunctionComponent<NodeViewProps> = React.memo(
return ( componentProps => {
<ReactNodeViewContext.Provider value={context}> return (
{React.createElement(Component, componentProps)} <ReactNodeViewContext.Provider value={context}>
</ReactNodeViewContext.Provider> {React.createElement(Component, componentProps)}
) </ReactNodeViewContext.Provider>
}) )
},
)
ReactNodeViewProvider.displayName = 'ReactNodeView' ReactNodeViewProvider.displayName = 'ReactNodeView'
@ -121,6 +152,10 @@ class ReactNodeView extends NodeView<
this.updateElementAttributes() this.updateElementAttributes()
} }
/**
* Return the DOM element.
* This is the element that will be used to display the node view.
*/
get dom() { get dom() {
if ( if (
this.renderer.element.firstElementChild this.renderer.element.firstElementChild
@ -132,6 +167,10 @@ class ReactNodeView extends NodeView<
return this.renderer.element as HTMLElement return this.renderer.element as HTMLElement
} }
/**
* Return the content DOM element.
* This is the element that will be used to display the rich-text content of the node.
*/
get contentDOM() { get contentDOM() {
if (this.node.isLeaf) { if (this.node.isLeaf) {
return null return null
@ -140,10 +179,19 @@ class ReactNodeView extends NodeView<
return this.contentDOMElement return this.contentDOMElement
} }
/**
* On editor selection update, check if the node is selected.
* If it is, call `selectNode`, otherwise call `deselectNode`.
*/
handleSelectionUpdate() { handleSelectionUpdate() {
const { from, to } = this.editor.state.selection const { from, to } = this.editor.state.selection
const pos = this.getPos()
if (from <= this.getPos() && to >= this.getPos() + this.node.nodeSize) { if (typeof pos !== 'number') {
return
}
if (from <= pos && to >= pos + this.node.nodeSize) {
if (this.renderer.props.selected) { if (this.renderer.props.selected) {
return return
} }
@ -158,8 +206,16 @@ class ReactNodeView extends NodeView<
} }
} }
update(node: ProseMirrorNode, decorations: DecorationWithType[]) { /**
const updateProps = (props?: Record<string, any>) => { * On update, update the React component.
* To prevent unnecessary updates, the `update` option can be used.
*/
update(
node: Node,
decorations: readonly Decoration[],
innerDecorations: DecorationSource,
): boolean {
const rerenderComponent = (props?: Record<string, any>) => {
this.renderer.updateProps(props) this.renderer.updateProps(props)
if (typeof this.options.attrs === 'function') { if (typeof this.options.attrs === 'function') {
this.updateElementAttributes() this.updateElementAttributes()
@ -173,31 +229,44 @@ class ReactNodeView extends NodeView<
if (typeof this.options.update === 'function') { if (typeof this.options.update === 'function') {
const oldNode = this.node const oldNode = this.node
const oldDecorations = this.decorations const oldDecorations = this.decorations
const oldInnerDecorations = this.innerDecorations
this.node = node this.node = node
this.decorations = decorations this.decorations = decorations
this.innerDecorations = innerDecorations
return this.options.update({ return this.options.update({
oldNode, oldNode,
oldDecorations, oldDecorations,
newNode: node, newNode: node,
newDecorations: decorations, newDecorations: decorations,
updateProps: () => updateProps({ node, decorations }), oldInnerDecorations,
innerDecorations,
updateProps: () => rerenderComponent({ node, decorations, innerDecorations }),
}) })
} }
if (node === this.node && this.decorations === decorations) { if (
node === this.node
&& this.decorations === decorations
&& this.innerDecorations === innerDecorations
) {
return true return true
} }
this.node = node this.node = node
this.decorations = decorations this.decorations = decorations
this.innerDecorations = innerDecorations
updateProps({ node, decorations }) rerenderComponent({ node, decorations, innerDecorations })
return true return true
} }
/**
* Select the node.
* Add the `selected` prop and the `ProseMirror-selectednode` class.
*/
selectNode() { selectNode() {
this.renderer.updateProps({ this.renderer.updateProps({
selected: true, selected: true,
@ -205,6 +274,10 @@ class ReactNodeView extends NodeView<
this.renderer.element.classList.add('ProseMirror-selectednode') this.renderer.element.classList.add('ProseMirror-selectednode')
} }
/**
* Deselect the node.
* Remove the `selected` prop and the `ProseMirror-selectednode` class.
*/
deselectNode() { deselectNode() {
this.renderer.updateProps({ this.renderer.updateProps({
selected: false, selected: false,
@ -212,12 +285,19 @@ class ReactNodeView extends NodeView<
this.renderer.element.classList.remove('ProseMirror-selectednode') this.renderer.element.classList.remove('ProseMirror-selectednode')
} }
/**
* Destroy the React component instance.
*/
destroy() { destroy() {
this.renderer.destroy() this.renderer.destroy()
this.editor.off('selectionUpdate', this.handleSelectionUpdate) this.editor.off('selectionUpdate', this.handleSelectionUpdate)
this.contentDOMElement = null this.contentDOMElement = null
} }
/**
* Update the attributes of the top-level element that holds the React component.
* Applying the attributes defined in the `attrs` option.
*/
updateElementAttributes() { updateElementAttributes() {
if (this.options.attrs) { if (this.options.attrs) {
let attrsObj: Record<string, string> = {} let attrsObj: Record<string, string> = {}
@ -236,18 +316,21 @@ class ReactNodeView extends NodeView<
} }
} }
/**
* Create a React node view renderer.
*/
export function ReactNodeViewRenderer( export function ReactNodeViewRenderer(
component: any, component: ComponentType<NodeViewProps>,
options?: Partial<ReactNodeViewRendererOptions>, options?: Partial<ReactNodeViewRendererOptions>,
): NodeViewRenderer { ): NodeViewRenderer {
return (props: NodeViewRendererProps) => { return props => {
// try to get the parent component // try to get the parent component
// this is important for vue devtools to show the component hierarchy correctly // this is important for vue devtools to show the component hierarchy correctly
// maybe its `undefined` because <editor-content> isnt rendered yet // maybe its `undefined` because <editor-content> isnt rendered yet
if (!(props.editor as EditorWithContentComponent).contentComponent) { if (!(props.editor as EditorWithContentComponent).contentComponent) {
return {} return {} as unknown as ProseMirrorNodeView
} }
return new ReactNodeView(component, props, options) as unknown as ProseMirrorNodeView return new ReactNodeView(component, props, options)
} }
} }

View File

@ -75,7 +75,7 @@ type ComponentType<R, P> =
* as: 'span', * as: 'span',
* }) * })
*/ */
export class ReactRenderer<R = unknown, P = unknown> { export class ReactRenderer<R = unknown, P extends Record<string, any> = {}> {
id: string id: string
editor: Editor editor: Editor
@ -84,12 +84,15 @@ export class ReactRenderer<R = unknown, P = unknown> {
element: Element element: Element
props: Record<string, any> props: P
reactElement: React.ReactNode reactElement: React.ReactNode
ref: R | null = null ref: R | null = null
/**
* Immediately creates element and renders the provided React component.
*/
constructor(component: ComponentType<R, P>, { constructor(component: ComponentType<R, P>, {
editor, editor,
props = {}, props = {},
@ -99,7 +102,7 @@ export class ReactRenderer<R = unknown, P = unknown> {
this.id = Math.floor(Math.random() * 0xFFFFFFFF).toString() this.id = Math.floor(Math.random() * 0xFFFFFFFF).toString()
this.component = component this.component = component
this.editor = editor as EditorWithContentComponent this.editor = editor as EditorWithContentComponent
this.props = props this.props = props as P
this.element = document.createElement(as) this.element = document.createElement(as)
this.element.classList.add('react-renderer') this.element.classList.add('react-renderer')
@ -118,12 +121,16 @@ export class ReactRenderer<R = unknown, P = unknown> {
} }
} }
/**
* Render the React component.
*/
render(): void { render(): void {
const Component = this.component const Component = this.component
const props = this.props const props = this.props
const editor = this.editor as EditorWithContentComponent const editor = this.editor as EditorWithContentComponent
if (isClassComponent(Component) || isForwardRefComponent(Component)) { if (isClassComponent(Component) || isForwardRefComponent(Component)) {
// @ts-ignore This is a hack to make the ref work
props.ref = (ref: R) => { props.ref = (ref: R) => {
this.ref = ref this.ref = ref
} }
@ -134,6 +141,9 @@ export class ReactRenderer<R = unknown, P = unknown> {
editor?.contentComponent?.setRenderer(this.id, this) editor?.contentComponent?.setRenderer(this.id, this)
} }
/**
* Re-renders the React component with new props.
*/
updateProps(props: Record<string, any> = {}): void { updateProps(props: Record<string, any> = {}): void {
this.props = { this.props = {
...this.props, ...this.props,
@ -143,12 +153,18 @@ export class ReactRenderer<R = unknown, P = unknown> {
this.render() this.render()
} }
/**
* Destroy the React component.
*/
destroy(): void { destroy(): void {
const editor = this.editor as EditorWithContentComponent const editor = this.editor as EditorWithContentComponent
editor?.contentComponent?.removeRenderer(this.id) editor?.contentComponent?.removeRenderer(this.id)
} }
/**
* Update the attributes of the element that holds the React component.
*/
updateAttributes(attributes: Record<string, string>): void { updateAttributes(attributes: Record<string, string>): void {
Object.keys(attributes).forEach(key => { Object.keys(attributes).forEach(key => {
this.element.setAttribute(key, attributes[key]) this.element.setAttribute(key, attributes[key])

View File

@ -4,10 +4,9 @@ import {
NodeViewProps, NodeViewProps,
NodeViewRenderer, NodeViewRenderer,
NodeViewRendererOptions, NodeViewRendererOptions,
NodeViewRendererProps,
} from '@tiptap/core' } from '@tiptap/core'
import { Node as ProseMirrorNode } from '@tiptap/pm/model' import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Decoration, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view' import { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
import Vue from 'vue' import Vue from 'vue'
import { VueConstructor } from 'vue/types/umd' import { VueConstructor } from 'vue/types/umd'
import { booleanProp, functionProp, objectProp } from 'vue-ts-types' import { booleanProp, functionProp, objectProp } from 'vue-ts-types'
@ -29,33 +28,38 @@ export const nodeViewProps = {
export interface VueNodeViewRendererOptions extends NodeViewRendererOptions { export interface VueNodeViewRendererOptions extends NodeViewRendererOptions {
update: update:
| ((props: { | ((props: {
oldNode: ProseMirrorNode oldNode: ProseMirrorNode;
oldDecorations: Decoration[] oldDecorations: readonly Decoration[];
newNode: ProseMirrorNode oldInnerDecorations: DecorationSource;
newDecorations: Decoration[] newNode: ProseMirrorNode;
updateProps: () => void newDecorations: readonly Decoration[];
innerDecorations: DecorationSource;
updateProps: () => void;
}) => boolean) }) => boolean)
| null | null;
} }
class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRendererOptions> { class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRendererOptions> {
renderer!: VueRenderer renderer!: VueRenderer
decorationClasses!: { decorationClasses!: {
value: string value: string;
} }
mount() { mount() {
const props: NodeViewProps = { const props = {
editor: this.editor, editor: this.editor,
node: this.node, node: this.node,
decorations: this.decorations, decorations: this.decorations as DecorationWithType[],
innerDecorations: this.innerDecorations,
view: this.view,
selected: false, selected: false,
extension: this.extension, extension: this.extension,
HTMLAttributes: this.HTMLAttributes,
getPos: () => this.getPos(), getPos: () => this.getPos(),
updateAttributes: (attributes = {}) => this.updateAttributes(attributes), updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
deleteNode: () => this.deleteNode(), deleteNode: () => this.deleteNode(),
} } satisfies NodeViewProps
const onDragStart = this.onDragStart.bind(this) const onDragStart = this.onDragStart.bind(this)
@ -64,7 +68,7 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
}) })
// @ts-ignore // @ts-ignore
const vue = this.editor.contentComponent?.$options._base ?? Vue // eslint-disable-line const vue = this.editor.contentComponent?.$options._base ?? Vue; // eslint-disable-line
const Component = vue.extend(this.component).extend({ const Component = vue.extend(this.component).extend({
props: Object.keys(props), props: Object.keys(props),
@ -76,12 +80,19 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
}, },
}) })
this.handleSelectionUpdate = this.handleSelectionUpdate.bind(this)
this.editor.on('selectionUpdate', this.handleSelectionUpdate)
this.renderer = new VueRenderer(Component, { this.renderer = new VueRenderer(Component, {
parent: this.editor.contentComponent, parent: this.editor.contentComponent,
propsData: props, propsData: props,
}) })
} }
/**
* Return the DOM element.
* This is the element that will be used to display the node view.
*/
get dom() { get dom() {
if (!this.renderer.element.hasAttribute('data-node-view-wrapper')) { if (!this.renderer.element.hasAttribute('data-node-view-wrapper')) {
throw Error('Please use the NodeViewWrapper component for your node view.') throw Error('Please use the NodeViewWrapper component for your node view.')
@ -90,6 +101,10 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
return this.renderer.element as HTMLElement return this.renderer.element as HTMLElement
} }
/**
* Return the content DOM element.
* This is the element that will be used to display the rich-text content of the node.
*/
get contentDOM() { get contentDOM() {
if (this.node.isLeaf) { if (this.node.isLeaf) {
return null return null
@ -100,8 +115,43 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
return (contentElement || this.dom) as HTMLElement | null return (contentElement || this.dom) as HTMLElement | null
} }
update(node: ProseMirrorNode, decorations: DecorationWithType[]) { /**
const updateProps = (props?: Record<string, any>) => { * On editor selection update, check if the node is selected.
* If it is, call `selectNode`, otherwise call `deselectNode`.
*/
handleSelectionUpdate() {
const { from, to } = this.editor.state.selection
const pos = this.getPos()
if (typeof pos !== 'number') {
return
}
if (from <= pos && to >= pos + this.node.nodeSize) {
if (this.renderer.ref.$props.selected) {
return
}
this.selectNode()
} else {
if (!this.renderer.ref.$props.selected) {
return
}
this.deselectNode()
}
}
/**
* On update, update the React component.
* To prevent unnecessary updates, the `update` option can be used.
*/
update(
node: ProseMirrorNode,
decorations: readonly Decoration[],
innerDecorations: DecorationSource,
): boolean {
const rerenderComponent = (props?: Record<string, any>) => {
this.decorationClasses.value = this.getDecorationClasses() this.decorationClasses.value = this.getDecorationClasses()
this.renderer.updateProps(props) this.renderer.updateProps(props)
} }
@ -109,16 +159,20 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
if (typeof this.options.update === 'function') { if (typeof this.options.update === 'function') {
const oldNode = this.node const oldNode = this.node
const oldDecorations = this.decorations const oldDecorations = this.decorations
const oldInnerDecorations = this.innerDecorations
this.node = node this.node = node
this.decorations = decorations this.decorations = decorations
this.innerDecorations = innerDecorations
return this.options.update({ return this.options.update({
oldNode, oldNode,
oldDecorations, oldDecorations,
newNode: node, newNode: node,
newDecorations: decorations, newDecorations: decorations,
updateProps: () => updateProps({ node, decorations }), oldInnerDecorations,
innerDecorations,
updateProps: () => rerenderComponent({ node, decorations, innerDecorations }),
}) })
} }
@ -126,18 +180,23 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
return false return false
} }
if (node === this.node && this.decorations === decorations) { if (node === this.node && this.decorations === decorations && this.innerDecorations === innerDecorations) {
return true return true
} }
this.node = node this.node = node
this.decorations = decorations this.decorations = decorations
this.innerDecorations = innerDecorations
updateProps({ node, decorations }) rerenderComponent({ node, decorations, innerDecorations })
return true return true
} }
/**
* Select the node.
* Add the `selected` prop and the `ProseMirror-selectednode` class.
*/
selectNode() { selectNode() {
this.renderer.updateProps({ this.renderer.updateProps({
selected: true, selected: true,
@ -145,6 +204,10 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
this.renderer.element.classList.add('ProseMirror-selectednode') this.renderer.element.classList.add('ProseMirror-selectednode')
} }
/**
* Deselect the node.
* Remove the `selected` prop and the `ProseMirror-selectednode` class.
*/
deselectNode() { deselectNode() {
this.renderer.updateProps({ this.renderer.updateProps({
selected: false, selected: false,
@ -164,6 +227,7 @@ class VueNodeView extends NodeView<Vue | VueConstructor, Editor, VueNodeViewRend
destroy() { destroy() {
this.renderer.destroy() this.renderer.destroy()
this.editor.off('selectionUpdate', this.handleSelectionUpdate)
} }
} }
@ -171,14 +235,14 @@ export function VueNodeViewRenderer(
component: Vue | VueConstructor, component: Vue | VueConstructor,
options?: Partial<VueNodeViewRendererOptions>, options?: Partial<VueNodeViewRendererOptions>,
): NodeViewRenderer { ): NodeViewRenderer {
return (props: NodeViewRendererProps) => { return props => {
// try to get the parent component // try to get the parent component
// this is important for vue devtools to show the component hierarchy correctly // this is important for vue devtools to show the component hierarchy correctly
// maybe its `undefined` because <editor-content> isnt rendered yet // maybe its `undefined` because <editor-content> isnt rendered yet
if (!(props.editor as Editor).contentComponent) { if (!(props.editor as Editor).contentComponent) {
return {} return {} as unknown as ProseMirrorNodeView
} }
return new VueNodeView(component, props, options) as unknown as ProseMirrorNodeView return new VueNodeView(component, props, options)
} }
} }

View File

@ -1,20 +1,15 @@
/* eslint-disable no-underscore-dangle */
import { import {
DecorationWithType, DecorationWithType,
NodeView, NodeView,
NodeViewProps, NodeViewProps,
NodeViewRenderer, NodeViewRenderer,
NodeViewRendererOptions, NodeViewRendererOptions,
NodeViewRendererProps,
} from '@tiptap/core' } from '@tiptap/core'
import { Node as ProseMirrorNode } from '@tiptap/pm/model' import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Decoration, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view' import { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
import { import {
Component, Component, defineComponent, PropType, provide, Ref, ref,
defineComponent,
PropType,
provide,
Ref,
ref,
} from 'vue' } from 'vue'
import { Editor } from './Editor.js' import { Editor } from './Editor.js'
@ -58,13 +53,15 @@ export const nodeViewProps = {
export interface VueNodeViewRendererOptions extends NodeViewRendererOptions { export interface VueNodeViewRendererOptions extends NodeViewRendererOptions {
update: update:
| ((props: { | ((props: {
oldNode: ProseMirrorNode oldNode: ProseMirrorNode;
oldDecorations: Decoration[] oldDecorations: readonly Decoration[];
newNode: ProseMirrorNode oldInnerDecorations: DecorationSource;
newDecorations: Decoration[] newNode: ProseMirrorNode;
updateProps: () => void newDecorations: readonly Decoration[];
innerDecorations: DecorationSource;
updateProps: () => void;
}) => boolean) }) => boolean)
| null | null;
} }
class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions> { class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions> {
@ -73,16 +70,19 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
decorationClasses!: Ref<string> decorationClasses!: Ref<string>
mount() { mount() {
const props: NodeViewProps = { const props = {
editor: this.editor, editor: this.editor,
node: this.node, node: this.node,
decorations: this.decorations, decorations: this.decorations as DecorationWithType[],
innerDecorations: this.innerDecorations,
view: this.view,
selected: false, selected: false,
extension: this.extension, extension: this.extension,
HTMLAttributes: this.HTMLAttributes,
getPos: () => this.getPos(), getPos: () => this.getPos(),
updateAttributes: (attributes = {}) => this.updateAttributes(attributes), updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
deleteNode: () => this.deleteNode(), deleteNode: () => this.deleteNode(),
} } satisfies NodeViewProps
const onDragStart = this.onDragStart.bind(this) const onDragStart = this.onDragStart.bind(this)
@ -117,12 +117,19 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
__file: this.component.__file, __file: this.component.__file,
}) })
this.handleSelectionUpdate = this.handleSelectionUpdate.bind(this)
this.editor.on('selectionUpdate', this.handleSelectionUpdate)
this.renderer = new VueRenderer(extendedComponent, { this.renderer = new VueRenderer(extendedComponent, {
editor: this.editor, editor: this.editor,
props, props,
}) })
} }
/**
* Return the DOM element.
* This is the element that will be used to display the node view.
*/
get dom() { get dom() {
if (!this.renderer.element || !this.renderer.element.hasAttribute('data-node-view-wrapper')) { if (!this.renderer.element || !this.renderer.element.hasAttribute('data-node-view-wrapper')) {
throw Error('Please use the NodeViewWrapper component for your node view.') throw Error('Please use the NodeViewWrapper component for your node view.')
@ -131,6 +138,10 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
return this.renderer.element as HTMLElement return this.renderer.element as HTMLElement
} }
/**
* Return the content DOM element.
* This is the element that will be used to display the rich-text content of the node.
*/
get contentDOM() { get contentDOM() {
if (this.node.isLeaf) { if (this.node.isLeaf) {
return null return null
@ -139,8 +150,43 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
return this.dom.querySelector('[data-node-view-content]') as HTMLElement | null return this.dom.querySelector('[data-node-view-content]') as HTMLElement | null
} }
update(node: ProseMirrorNode, decorations: DecorationWithType[]) { /**
const updateProps = (props?: Record<string, any>) => { * On editor selection update, check if the node is selected.
* If it is, call `selectNode`, otherwise call `deselectNode`.
*/
handleSelectionUpdate() {
const { from, to } = this.editor.state.selection
const pos = this.getPos()
if (typeof pos !== 'number') {
return
}
if (from <= pos && to >= pos + this.node.nodeSize) {
if (this.renderer.props.selected) {
return
}
this.selectNode()
} else {
if (!this.renderer.props.selected) {
return
}
this.deselectNode()
}
}
/**
* On update, update the React component.
* To prevent unnecessary updates, the `update` option can be used.
*/
update(
node: ProseMirrorNode,
decorations: readonly Decoration[],
innerDecorations: DecorationSource,
): boolean {
const rerenderComponent = (props?: Record<string, any>) => {
this.decorationClasses.value = this.getDecorationClasses() this.decorationClasses.value = this.getDecorationClasses()
this.renderer.updateProps(props) this.renderer.updateProps(props)
} }
@ -148,16 +194,20 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
if (typeof this.options.update === 'function') { if (typeof this.options.update === 'function') {
const oldNode = this.node const oldNode = this.node
const oldDecorations = this.decorations const oldDecorations = this.decorations
const oldInnerDecorations = this.innerDecorations
this.node = node this.node = node
this.decorations = decorations this.decorations = decorations
this.innerDecorations = innerDecorations
return this.options.update({ return this.options.update({
oldNode, oldNode,
oldDecorations, oldDecorations,
newNode: node, newNode: node,
newDecorations: decorations, newDecorations: decorations,
updateProps: () => updateProps({ node, decorations }), oldInnerDecorations,
innerDecorations,
updateProps: () => rerenderComponent({ node, decorations, innerDecorations }),
}) })
} }
@ -165,18 +215,23 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
return false return false
} }
if (node === this.node && this.decorations === decorations) { if (node === this.node && this.decorations === decorations && this.innerDecorations === innerDecorations) {
return true return true
} }
this.node = node this.node = node
this.decorations = decorations this.decorations = decorations
this.innerDecorations = innerDecorations
updateProps({ node, decorations }) rerenderComponent({ node, decorations, innerDecorations })
return true return true
} }
/**
* Select the node.
* Add the `selected` prop and the `ProseMirror-selectednode` class.
*/
selectNode() { selectNode() {
this.renderer.updateProps({ this.renderer.updateProps({
selected: true, selected: true,
@ -186,6 +241,10 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
} }
} }
/**
* Deselect the node.
* Remove the `selected` prop and the `ProseMirror-selectednode` class.
*/
deselectNode() { deselectNode() {
this.renderer.updateProps({ this.renderer.updateProps({
selected: false, selected: false,
@ -207,26 +266,26 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
destroy() { destroy() {
this.renderer.destroy() this.renderer.destroy()
this.editor.off('selectionUpdate', this.handleSelectionUpdate)
} }
} }
export function VueNodeViewRenderer( export function VueNodeViewRenderer(
component: Component, component: Component<NodeViewProps>,
options?: Partial<VueNodeViewRendererOptions>, options?: Partial<VueNodeViewRendererOptions>,
): NodeViewRenderer { ): NodeViewRenderer {
return (props: NodeViewRendererProps) => { return props => {
// try to get the parent component // try to get the parent component
// this is important for vue devtools to show the component hierarchy correctly // this is important for vue devtools to show the component hierarchy correctly
// maybe its `undefined` because <editor-content> isnt rendered yet // maybe its `undefined` because <editor-content> isnt rendered yet
if (!(props.editor as Editor).contentComponent) { if (!(props.editor as Editor).contentComponent) {
return {} return {} as unknown as ProseMirrorNodeView
} }
// check for class-component and normalize if neccessary // check for class-component and normalize if neccessary
const normalizedComponent = typeof component === 'function' && '__vccOpts' in component const normalizedComponent = typeof component === 'function' && '__vccOpts' in component
// eslint-disable-next-line no-underscore-dangle ? (component.__vccOpts as Component)
? component.__vccOpts as Component
: component : component
return new VueNodeView(normalizedComponent, props, options) as unknown as ProseMirrorNodeView return new VueNodeView(normalizedComponent, props, options)
} }
} }