fix(vue-3): reapply performance updates (#5373)

---------

Co-authored-by: relchapt <reynald.lechapt@getmayday.co>
Co-authored-by: Rirax <rlechapt@student.42.fr>
Co-authored-by: Segev Finer <segev@swimm.io>
This commit is contained in:
Nick Perez 2024-07-22 13:18:03 +02:00 committed by GitHub
parent a64cbf8f8a
commit ab8389a32c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 88 additions and 66 deletions

View File

@ -0,0 +1,5 @@
---
"@tiptap/vue-3": patch
---
VueNodeViewRenderer should return `null` for `contentDOM` for a non-leaf node with no `NodeViewContent`

View File

@ -404,6 +404,11 @@ export class Editor extends EventEmitter<EditorEvents> {
const state = this.state.apply(transaction) const state = this.state.apply(transaction)
const selectionHasChanged = !this.state.selection.eq(state.selection) const selectionHasChanged = !this.state.selection.eq(state.selection)
this.emit('beforeTransaction', {
editor: this,
transaction,
nextState: state,
})
this.view.updateState(state) this.view.updateState(state)
this.emit('transaction', { this.emit('transaction', {
editor: this, editor: this,

View File

@ -50,6 +50,7 @@ export interface EditorEvents {
} }
update: { editor: Editor; transaction: Transaction } update: { editor: Editor; transaction: Transaction }
selectionUpdate: { editor: Editor; transaction: Transaction } selectionUpdate: { editor: Editor; transaction: Transaction }
beforeTransaction: { editor: Editor; transaction: Transaction, nextState: EditorState }
transaction: { editor: Editor; transaction: Transaction } transaction: { editor: Editor; transaction: Transaction }
focus: { editor: Editor; event: FocusEvent; transaction: Transaction } focus: { editor: Editor; event: FocusEvent; transaction: Transaction }
blur: { editor: Editor; event: FocusEvent; transaction: Transaction } blur: { editor: Editor; event: FocusEvent; transaction: Transaction }

View File

@ -1,16 +1,14 @@
import { Editor as CoreEditor, EditorOptions } from '@tiptap/core' import { Editor as CoreEditor, EditorOptions } from '@tiptap/core'
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state' import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
import { import {
AppContext,
ComponentInternalInstance, ComponentInternalInstance,
ComponentPublicInstance, ComponentPublicInstance,
customRef, customRef,
markRaw, markRaw,
reactive,
Ref, Ref,
} from 'vue' } from 'vue'
import { VueRenderer } from './VueRenderer.js'
function useDebouncedRef<T>(value: T) { function useDebouncedRef<T>(value: T) {
return customRef<T>((track, trigger) => { return customRef<T>((track, trigger) => {
return { return {
@ -42,18 +40,18 @@ export class Editor extends CoreEditor {
private reactiveExtensionStorage: Ref<Record<string, any>> private reactiveExtensionStorage: Ref<Record<string, any>>
public vueRenderers = reactive<Map<string, VueRenderer>>(new Map())
public contentComponent: ContentComponent | null = null public contentComponent: ContentComponent | null = null
public appContext: AppContext | null = null
constructor(options: Partial<EditorOptions> = {}) { constructor(options: Partial<EditorOptions> = {}) {
super(options) super(options)
this.reactiveState = useDebouncedRef(this.view.state) this.reactiveState = useDebouncedRef(this.view.state)
this.reactiveExtensionStorage = useDebouncedRef(this.extensionStorage) this.reactiveExtensionStorage = useDebouncedRef(this.extensionStorage)
this.on('transaction', () => { this.on('beforeTransaction', ({ nextState }) => {
this.reactiveState.value = this.view.state this.reactiveState.value = nextState
this.reactiveExtensionStorage.value = this.extensionStorage this.reactiveExtensionStorage.value = this.extensionStorage
}) })

View File

@ -1,5 +1,4 @@
import { import {
DefineComponent,
defineComponent, defineComponent,
getCurrentInstance, getCurrentInstance,
h, h,
@ -8,7 +7,6 @@ import {
PropType, PropType,
Ref, Ref,
ref, ref,
Teleport,
unref, unref,
watchEffect, watchEffect,
} from 'vue' } from 'vue'
@ -45,6 +43,17 @@ export const EditorContent = defineComponent({
// @ts-ignore // @ts-ignore
editor.contentComponent = instance.ctx._ editor.contentComponent = instance.ctx._
if (instance) {
editor.appContext = {
...instance.appContext,
provides: {
// @ts-ignore
...instance.provides,
...instance.appContext.provides,
},
}
}
editor.setOptions({ editor.setOptions({
element, element,
}) })
@ -69,6 +78,7 @@ export const EditorContent = defineComponent({
} }
editor.contentComponent = null editor.contentComponent = null
editor.appContext = null
if (!editor.options.element.firstChild) { if (!editor.options.element.firstChild) {
return return
@ -87,35 +97,11 @@ export const EditorContent = defineComponent({
}, },
render() { render() {
const vueRenderers: any[] = []
if (this.editor) {
this.editor.vueRenderers.forEach(vueRenderer => {
const node = h(
Teleport,
{
to: vueRenderer.teleportElement,
key: vueRenderer.id,
},
h(
vueRenderer.component as DefineComponent,
{
ref: vueRenderer.id,
...vueRenderer.props,
},
),
)
vueRenderers.push(node)
})
}
return h( return h(
'div', 'div',
{ {
ref: (el: any) => { this.rootEl = el }, ref: (el: any) => { this.rootEl = el },
}, },
...vueRenderers,
) )
}, },
}) })

View File

@ -124,7 +124,7 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
} }
get dom() { get dom() {
if (!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.')
} }
@ -136,9 +136,7 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
return null return null
} }
const contentElement = this.dom.querySelector('[data-node-view-content]') return this.dom.querySelector('[data-node-view-content]') as HTMLElement | null
return (contentElement || this.dom) as HTMLElement | null
} }
update(node: ProseMirrorNode, decorations: DecorationWithType[]) { update(node: ProseMirrorNode, decorations: DecorationWithType[]) {
@ -183,15 +181,19 @@ class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions
this.renderer.updateProps({ this.renderer.updateProps({
selected: true, selected: true,
}) })
if (this.renderer.element) {
this.renderer.element.classList.add('ProseMirror-selectednode') this.renderer.element.classList.add('ProseMirror-selectednode')
} }
}
deselectNode() { deselectNode() {
this.renderer.updateProps({ this.renderer.updateProps({
selected: false, selected: false,
}) })
if (this.renderer.element) {
this.renderer.element.classList.remove('ProseMirror-selectednode') this.renderer.element.classList.remove('ProseMirror-selectednode')
} }
}
getDecorationClasses() { getDecorationClasses() {
return ( return (

View File

@ -1,62 +1,87 @@
import { Editor } from '@tiptap/core' import { Editor } from '@tiptap/core'
import { Component, markRaw, reactive } from 'vue' import {
Component, DefineComponent, h, markRaw, reactive, render,
} from 'vue'
import { Editor as ExtendedEditor } from './Editor.js' import { Editor as ExtendedEditor } from './Editor.js'
export interface VueRendererOptions { export interface VueRendererOptions {
editor: Editor, editor: Editor;
props?: Record<string, any>, props?: Record<string, any>;
}
type ExtendedVNode = ReturnType<typeof h> | null;
interface RenderedComponent {
vNode: ExtendedVNode;
destroy: () => void;
el: Element | null;
} }
/** /**
* This class is used to render Vue components inside the editor. * This class is used to render Vue components inside the editor.
*/ */
export class VueRenderer { export class VueRenderer {
id: string renderedComponent!: RenderedComponent
editor: ExtendedEditor editor: ExtendedEditor
component: Component component: Component
teleportElement: Element el: Element | null
element: Element
props: Record<string, any> props: Record<string, any>
constructor(component: Component, { props = {}, editor }: VueRendererOptions) { constructor(component: Component, { props = {}, editor }: VueRendererOptions) {
this.id = Math.floor(Math.random() * 0xFFFFFFFF).toString()
this.editor = editor as ExtendedEditor this.editor = editor as ExtendedEditor
this.component = markRaw(component) this.component = markRaw(component)
this.teleportElement = document.createElement('div') this.el = document.createElement('div')
this.element = this.teleportElement
this.props = reactive(props) this.props = reactive(props)
this.editor.vueRenderers.set(this.id, this) this.renderedComponent = this.renderComponent()
if (this.editor.contentComponent) {
this.editor.contentComponent.update()
if (this.teleportElement.children.length !== 1) {
throw Error('VueRenderer doesnt support multiple child elements.')
} }
this.element = this.teleportElement.firstElementChild as Element get element(): Element | null {
} return this.renderedComponent.el
} }
get ref(): any { get ref(): any {
return this.editor.contentComponent?.refs[this.id] // Composition API
if (this.renderedComponent.vNode?.component?.exposed) {
return this.renderedComponent.vNode.component.exposed
}
// Option API
return this.renderedComponent.vNode?.component?.proxy
}
renderComponent() {
let vNode: ExtendedVNode = h(this.component as DefineComponent, this.props)
if (this.editor.appContext) {
vNode.appContext = this.editor.appContext
}
if (typeof document !== 'undefined' && this.el) {
render(vNode, this.el)
}
const destroy = () => {
if (this.el) {
render(null, this.el)
}
this.el = null
vNode = null
}
return { vNode, destroy, el: this.el ? this.el.firstElementChild : null }
} }
updateProps(props: Record<string, any> = {}): void { updateProps(props: Record<string, any> = {}): void {
Object Object.entries(props).forEach(([key, value]) => {
.entries(props)
.forEach(([key, value]) => {
this.props[key] = value this.props[key] = value
}) })
this.renderComponent()
} }
destroy(): void { destroy(): void {
this.editor.vueRenderers.delete(this.id) this.renderedComponent.destroy()
} }
} }