snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
This commit is contained in:
93
packages/vue/src/Editor.ts
Normal file
93
packages/vue/src/Editor.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/* oslint-disable react-hooks/rules-of-hooks */
|
||||
import type { EditorOptions, Storage } from '@tiptap/core'
|
||||
import { Editor as CoreEditor } from '@tiptap/core'
|
||||
import type { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import type { AppContext, ComponentInternalInstance, ComponentPublicInstance, Ref } from 'vue'
|
||||
import { customRef, markRaw } from 'vue'
|
||||
|
||||
function useDebouncedRef<T>(value: T) {
|
||||
return customRef<T>((track, trigger) => {
|
||||
return {
|
||||
get() {
|
||||
track()
|
||||
return value
|
||||
},
|
||||
set(newValue) {
|
||||
// update state
|
||||
value = newValue
|
||||
|
||||
// update view as soon as possible
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
trigger()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type ContentComponent = ComponentInternalInstance & {
|
||||
ctx: ComponentPublicInstance
|
||||
}
|
||||
|
||||
export class Editor extends CoreEditor {
|
||||
private reactiveState: Ref<EditorState>
|
||||
|
||||
private reactiveExtensionStorage: Ref<Storage>
|
||||
|
||||
public contentComponent: ContentComponent | null = null
|
||||
|
||||
public appContext: AppContext | null = null
|
||||
|
||||
constructor(options: Partial<EditorOptions> = {}) {
|
||||
super(options)
|
||||
|
||||
this.reactiveState = useDebouncedRef(this.view.state)
|
||||
this.reactiveExtensionStorage = useDebouncedRef(this.extensionStorage)
|
||||
|
||||
this.on('beforeTransaction', ({ nextState }) => {
|
||||
this.reactiveState.value = nextState
|
||||
this.reactiveExtensionStorage.value = this.extensionStorage
|
||||
})
|
||||
|
||||
return markRaw(this) // oxlint-disable-line
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this.reactiveState ? this.reactiveState.value : this.view.state
|
||||
}
|
||||
|
||||
get storage() {
|
||||
return this.reactiveExtensionStorage ? this.reactiveExtensionStorage.value : super.storage
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a ProseMirror plugin.
|
||||
*/
|
||||
public registerPlugin(
|
||||
plugin: Plugin,
|
||||
handlePlugins?: (newPlugin: Plugin, plugins: Plugin[]) => Plugin[],
|
||||
): EditorState {
|
||||
const nextState = super.registerPlugin(plugin, handlePlugins)
|
||||
|
||||
if (this.reactiveState) {
|
||||
this.reactiveState.value = nextState
|
||||
}
|
||||
|
||||
return nextState
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a ProseMirror plugin.
|
||||
*/
|
||||
public unregisterPlugin(nameOrPluginKey: string | PluginKey): EditorState | undefined {
|
||||
const nextState = super.unregisterPlugin(nameOrPluginKey)
|
||||
|
||||
if (this.reactiveState && nextState) {
|
||||
this.reactiveState.value = nextState
|
||||
}
|
||||
|
||||
return nextState
|
||||
}
|
||||
}
|
||||
86
packages/vue/src/EditorContent.ts
Normal file
86
packages/vue/src/EditorContent.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { PropType, Ref } from 'vue'
|
||||
import {
|
||||
defineComponent,
|
||||
getCurrentInstance,
|
||||
h,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
ref,
|
||||
unref,
|
||||
watchEffect,
|
||||
} from 'vue'
|
||||
|
||||
import type { Editor } from './Editor.js'
|
||||
|
||||
export const EditorContent = defineComponent({
|
||||
name: 'EditorContent',
|
||||
|
||||
props: {
|
||||
editor: {
|
||||
default: null,
|
||||
type: Object as PropType<Editor>,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const rootEl: Ref<Element | undefined> = ref()
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
watchEffect(() => {
|
||||
const editor = props.editor
|
||||
|
||||
if (editor && editor.options.element && rootEl.value) {
|
||||
nextTick(() => {
|
||||
if (!rootEl.value || !editor.view.dom?.parentNode) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO using the new editor.mount method might allow us to remove this
|
||||
const element = unref(rootEl.value)
|
||||
|
||||
rootEl.value.append(...editor.view.dom.parentNode.childNodes)
|
||||
|
||||
// @ts-ignore
|
||||
editor.contentComponent = instance.ctx._
|
||||
|
||||
if (instance) {
|
||||
editor.appContext = {
|
||||
...instance.appContext,
|
||||
// Vue internally uses prototype chain to forward/shadow injects across the entire component chain
|
||||
// so don't use object spread operator or 'Object.assign' and just set `provides` as is on editor's appContext
|
||||
// @ts-expect-error forward instance's 'provides' into appContext
|
||||
provides: instance.provides,
|
||||
}
|
||||
}
|
||||
|
||||
editor.setOptions({
|
||||
element,
|
||||
})
|
||||
|
||||
editor.createNodeViews()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = props.editor
|
||||
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
editor.contentComponent = null
|
||||
editor.appContext = null
|
||||
})
|
||||
|
||||
return { rootEl }
|
||||
},
|
||||
|
||||
render() {
|
||||
return h('div', {
|
||||
ref: (el: any) => {
|
||||
this.rootEl = el
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
39
packages/vue/src/NodeViewContent.ts
Normal file
39
packages/vue/src/NodeViewContent.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
export const NodeViewContent = defineComponent({
|
||||
name: 'NodeViewContent',
|
||||
|
||||
props: {
|
||||
as: {
|
||||
type: String,
|
||||
default: 'div',
|
||||
},
|
||||
},
|
||||
|
||||
inject: {
|
||||
nodeViewContentRef: { default: undefined },
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const ref = (this as any).nodeViewContentRef as ((el: HTMLElement | null) => void) | undefined
|
||||
if (ref && this.$el) {
|
||||
ref(this.$el)
|
||||
}
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
const ref = (this as any).nodeViewContentRef as ((el: HTMLElement | null) => void) | undefined
|
||||
if (ref) {
|
||||
ref(null)
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
return h(this.as, {
|
||||
style: {
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
'data-node-view-content': '',
|
||||
})
|
||||
},
|
||||
})
|
||||
31
packages/vue/src/NodeViewWrapper.ts
Normal file
31
packages/vue/src/NodeViewWrapper.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
export const NodeViewWrapper = defineComponent({
|
||||
name: 'NodeViewWrapper',
|
||||
|
||||
props: {
|
||||
as: {
|
||||
type: String,
|
||||
default: 'div',
|
||||
},
|
||||
},
|
||||
|
||||
inject: ['onDragStart', 'decorationClasses'],
|
||||
|
||||
render() {
|
||||
return h(
|
||||
this.as,
|
||||
{
|
||||
// @ts-ignore
|
||||
class: this.decorationClasses,
|
||||
style: {
|
||||
whiteSpace: 'normal',
|
||||
},
|
||||
'data-node-view-wrapper': '',
|
||||
// @ts-ignore (https://github.com/vuejs/vue-next/issues/3031)
|
||||
onDragstart: this.onDragStart,
|
||||
},
|
||||
this.$slots.default?.(),
|
||||
)
|
||||
},
|
||||
})
|
||||
140
packages/vue/src/VueMarkViewRenderer.test.ts
Normal file
140
packages/vue/src/VueMarkViewRenderer.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Editor, Mark, mergeAttributes } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import { MarkViewContent, markViewProps, VueMarkViewRenderer } from './VueMarkViewRenderer.js'
|
||||
|
||||
// Create a simple Vue component for the mark view
|
||||
const TestMarkComponent = defineComponent({
|
||||
name: 'TestMarkComponent',
|
||||
props: markViewProps,
|
||||
setup() {
|
||||
return {}
|
||||
},
|
||||
template: '<mark><MarkViewContent /></mark>',
|
||||
components: { MarkViewContent },
|
||||
})
|
||||
|
||||
// Create a custom mark that uses VueMarkViewRenderer
|
||||
const CustomMark = Mark.create({
|
||||
name: 'customMark',
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'mark' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['mark', mergeAttributes(HTMLAttributes), 0]
|
||||
},
|
||||
|
||||
addMarkView() {
|
||||
return VueMarkViewRenderer(TestMarkComponent)
|
||||
},
|
||||
})
|
||||
|
||||
describe('VueMarkViewRenderer IME composition', () => {
|
||||
let editor: Editor | null = null
|
||||
let editorElement: HTMLElement | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
// Create editor element
|
||||
editorElement = document.createElement('div')
|
||||
document.body.appendChild(editorElement)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up
|
||||
if (editor) {
|
||||
editor.destroy()
|
||||
editor = null
|
||||
}
|
||||
if (editorElement && editorElement.parentNode) {
|
||||
editorElement.parentNode.removeChild(editorElement)
|
||||
editorElement = null
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle rapid content changes without errors', async () => {
|
||||
// Create editor with custom mark
|
||||
editor = new Editor({
|
||||
element: editorElement!,
|
||||
extensions: [Document, Paragraph, Text, CustomMark],
|
||||
content: '<p>This is <mark>marked text</mark> here.</p>',
|
||||
})
|
||||
|
||||
// Wait for editor to initialize
|
||||
await new Promise(resolve => {
|
||||
setTimeout(() => resolve(undefined), 10)
|
||||
})
|
||||
|
||||
// Get the mark view element
|
||||
const markElement = editorElement!.querySelector('mark')
|
||||
expect(markElement).toBeTruthy()
|
||||
|
||||
// Rapidly change content which could trigger mark view recreation
|
||||
// This simulates what happens during IME composition
|
||||
expect(() => {
|
||||
const { tr } = editor.state
|
||||
editor.view.dispatch(tr.insertText('a', 15))
|
||||
editor.view.dispatch(editor.state.tr.insertText('b', 16))
|
||||
editor.view.dispatch(editor.state.tr.insertText('c', 17))
|
||||
}).not.toThrow()
|
||||
|
||||
// Mark should still be present
|
||||
const markAfterChanges = editorElement!.querySelector('mark')
|
||||
expect(markAfterChanges).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should allow multiple destroy calls without errors', async () => {
|
||||
// Create editor with custom mark
|
||||
editor = new Editor({
|
||||
element: editorElement!,
|
||||
extensions: [Document, Paragraph, Text, CustomMark],
|
||||
content: '<p>This is <mark>marked text</mark> here.</p>',
|
||||
})
|
||||
|
||||
await new Promise(resolve => {
|
||||
setTimeout(() => resolve(undefined), 10)
|
||||
})
|
||||
|
||||
// Verify mark is rendered
|
||||
const markElement = editorElement!.querySelector('mark')
|
||||
expect(markElement).toBeTruthy()
|
||||
|
||||
// Destroy editor (which destroys all mark views)
|
||||
editor.destroy()
|
||||
|
||||
// Try to destroy again - should not throw
|
||||
expect(() => {
|
||||
editor!.destroy()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle content replacement without errors', async () => {
|
||||
editor = new Editor({
|
||||
element: editorElement!,
|
||||
extensions: [Document, Paragraph, Text, CustomMark],
|
||||
content: '<p>This is <mark>marked text</mark> here.</p>',
|
||||
})
|
||||
|
||||
await new Promise(resolve => {
|
||||
setTimeout(() => resolve(undefined), 10)
|
||||
})
|
||||
|
||||
const markElement = editorElement!.querySelector('mark')
|
||||
expect(markElement).toBeTruthy()
|
||||
|
||||
// Replace content entirely, which destroys and recreates mark views
|
||||
expect(() => {
|
||||
editor.commands.setContent('<p>New content with <mark>new mark</mark>!</p>')
|
||||
}).not.toThrow()
|
||||
|
||||
// New mark should be present
|
||||
const newMarkElement = editorElement!.querySelector('mark')
|
||||
expect(newMarkElement).toBeTruthy()
|
||||
expect(newMarkElement?.textContent).toBe('new mark')
|
||||
})
|
||||
})
|
||||
137
packages/vue/src/VueMarkViewRenderer.ts
Normal file
137
packages/vue/src/VueMarkViewRenderer.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/* oslint-disable no-underscore-dangle */
|
||||
import type { MarkViewProps, MarkViewRenderer, MarkViewRendererOptions } from '@tiptap/core'
|
||||
import { MarkView } from '@tiptap/core'
|
||||
import type { Component, PropType } from 'vue'
|
||||
import { defineComponent, h, toRaw } from 'vue'
|
||||
|
||||
import type { Editor } from './Editor.js'
|
||||
import { VueRenderer } from './VueRenderer.js'
|
||||
|
||||
export interface VueMarkViewRendererOptions extends MarkViewRendererOptions {
|
||||
as?: string
|
||||
className?: string
|
||||
attrs?: { [key: string]: string }
|
||||
}
|
||||
|
||||
export const markViewProps = {
|
||||
editor: {
|
||||
type: Object as PropType<MarkViewProps['editor']>,
|
||||
required: true as const,
|
||||
},
|
||||
mark: {
|
||||
type: Object as PropType<MarkViewProps['mark']>,
|
||||
required: true as const,
|
||||
},
|
||||
extension: {
|
||||
type: Object as PropType<MarkViewProps['extension']>,
|
||||
required: true as const,
|
||||
},
|
||||
inline: {
|
||||
type: Boolean as PropType<MarkViewProps['inline']>,
|
||||
required: true as const,
|
||||
},
|
||||
view: {
|
||||
type: Object as PropType<MarkViewProps['view']>,
|
||||
required: true as const,
|
||||
},
|
||||
updateAttributes: {
|
||||
type: Function as PropType<MarkViewProps['updateAttributes']>,
|
||||
required: true as const,
|
||||
},
|
||||
HTMLAttributes: {
|
||||
type: Object as PropType<MarkViewProps['HTMLAttributes']>,
|
||||
required: true as const,
|
||||
},
|
||||
}
|
||||
|
||||
export const MarkViewContent = defineComponent({
|
||||
name: 'MarkViewContent',
|
||||
|
||||
props: {
|
||||
as: {
|
||||
type: String,
|
||||
default: 'span',
|
||||
},
|
||||
},
|
||||
|
||||
render() {
|
||||
return h(this.as, {
|
||||
style: {
|
||||
whiteSpace: 'inherit',
|
||||
},
|
||||
'data-mark-view-content': '',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export class VueMarkView extends MarkView<Component, VueMarkViewRendererOptions> {
|
||||
renderer: VueRenderer
|
||||
|
||||
constructor(
|
||||
component: Component,
|
||||
props: MarkViewProps,
|
||||
options?: Partial<VueMarkViewRendererOptions>,
|
||||
) {
|
||||
super(component, props, options)
|
||||
|
||||
const componentProps = {
|
||||
...props,
|
||||
updateAttributes: this.updateAttributes.bind(this),
|
||||
} satisfies MarkViewProps
|
||||
|
||||
// Create extended component with provide
|
||||
const extendedComponent = defineComponent({
|
||||
extends: { ...component },
|
||||
props: Object.keys(componentProps),
|
||||
template: (this.component as any).template,
|
||||
setup: reactiveProps => {
|
||||
return (component as any).setup?.(reactiveProps, {
|
||||
expose: () => undefined,
|
||||
})
|
||||
},
|
||||
// Add support for scoped styles
|
||||
__scopeId: (component as any).__scopeId,
|
||||
__cssModules: (component as any).__cssModules,
|
||||
__name: (component as any).__name,
|
||||
__file: (component as any).__file,
|
||||
})
|
||||
this.renderer = new VueRenderer(extendedComponent, {
|
||||
editor: this.editor,
|
||||
props: componentProps,
|
||||
})
|
||||
}
|
||||
|
||||
get dom() {
|
||||
return this.renderer.element as HTMLElement
|
||||
}
|
||||
|
||||
get contentDOM() {
|
||||
return this.dom.querySelector('[data-mark-view-content]') as HTMLElement | null
|
||||
}
|
||||
|
||||
updateAttributes(attrs: Record<string, any>): void {
|
||||
// since this.mark is now an proxy, we need to get the actual mark from it
|
||||
const unproxiedMark = toRaw(this.mark)
|
||||
super.updateAttributes(attrs, unproxiedMark)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export function VueMarkViewRenderer(
|
||||
component: Component,
|
||||
options: Partial<VueMarkViewRendererOptions> = {},
|
||||
): MarkViewRenderer {
|
||||
return props => {
|
||||
// try to get the parent component
|
||||
// this is important for vue devtools to show the component hierarchy correctly
|
||||
// maybe it’s `undefined` because <editor-content> isn’t rendered yet
|
||||
if (!(props.editor as Editor).contentComponent) {
|
||||
return {} as unknown as MarkView<any, any>
|
||||
}
|
||||
|
||||
return new VueMarkView(component, props, options)
|
||||
}
|
||||
}
|
||||
187
packages/vue/src/VueNodeViewRenderer.test.ts
Normal file
187
packages/vue/src/VueNodeViewRenderer.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Editor, Node, mergeAttributes } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import { Editor as VueEditor } from './Editor.js'
|
||||
import { NodeViewContent } from './NodeViewContent.js'
|
||||
import { NodeViewWrapper } from './NodeViewWrapper.js'
|
||||
import {
|
||||
nodeViewProps,
|
||||
VueNodeViewRenderer,
|
||||
type VueNodeViewRendererOptions,
|
||||
} from './VueNodeViewRenderer.js'
|
||||
|
||||
const ComponentWithoutContent = defineComponent({
|
||||
name: 'WithoutContent',
|
||||
props: nodeViewProps,
|
||||
template:
|
||||
'<node-view-wrapper class="no-content"><div class="custom-content">Custom UI</div></node-view-wrapper>',
|
||||
components: { NodeViewWrapper },
|
||||
})
|
||||
|
||||
const ComponentWithContent = defineComponent({
|
||||
name: 'WithContent',
|
||||
props: nodeViewProps,
|
||||
template:
|
||||
'<node-view-wrapper class="with-content"><node-view-content class="content-editable" /></node-view-wrapper>',
|
||||
components: { NodeViewWrapper, NodeViewContent },
|
||||
})
|
||||
|
||||
const ComponentWithTbodyContent = defineComponent({
|
||||
name: 'WithTbodyContent',
|
||||
props: nodeViewProps,
|
||||
template: '<node-view-wrapper as="table"><node-view-content as="tbody" /></node-view-wrapper>',
|
||||
components: { NodeViewWrapper, NodeViewContent },
|
||||
})
|
||||
|
||||
const LeafComponent = defineComponent({
|
||||
name: 'LeafComponent',
|
||||
props: nodeViewProps,
|
||||
template:
|
||||
'<node-view-wrapper class="leaf"><div class="leaf-content">Leaf</div></node-view-wrapper>',
|
||||
components: { NodeViewWrapper },
|
||||
})
|
||||
|
||||
function createBlockNode(
|
||||
component: ReturnType<typeof defineComponent>,
|
||||
options?: Partial<VueNodeViewRendererOptions>,
|
||||
) {
|
||||
return Node.create({
|
||||
name: 'customBlock',
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
parseHTML: () => [{ tag: 'custom-block' }],
|
||||
renderHTML: ({ HTMLAttributes }) => ['custom-block', mergeAttributes(HTMLAttributes), 0],
|
||||
addNodeView: () => VueNodeViewRenderer(component, options),
|
||||
})
|
||||
}
|
||||
|
||||
function createLeafNode(component: ReturnType<typeof defineComponent>) {
|
||||
return Node.create({
|
||||
name: 'customLeaf',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
parseHTML: () => [{ tag: 'custom-leaf' }],
|
||||
renderHTML: ({ HTMLAttributes }) => ['custom-leaf', mergeAttributes(HTMLAttributes)],
|
||||
addNodeView: () => VueNodeViewRenderer(component),
|
||||
})
|
||||
}
|
||||
|
||||
function createVueEditor(options: ConstructorParameters<typeof VueEditor>[0]) {
|
||||
const desc = Object.getOwnPropertyDescriptor(VueEditor.prototype, 'contentComponent')
|
||||
Object.defineProperty(VueEditor.prototype, 'contentComponent', {
|
||||
value: {},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const instance = new VueEditor(options)
|
||||
if (desc) Object.defineProperty(VueEditor.prototype, 'contentComponent', desc)
|
||||
else delete (VueEditor.prototype as any).contentComponent
|
||||
return instance
|
||||
}
|
||||
|
||||
describe('VueNodeViewRenderer contentDOM', () => {
|
||||
let editor: Editor | null = null
|
||||
let el: HTMLElement | null = null
|
||||
|
||||
function withEditor(
|
||||
content: string,
|
||||
component: ReturnType<typeof defineComponent>,
|
||||
leaf = false,
|
||||
options?: Partial<VueNodeViewRendererOptions>,
|
||||
) {
|
||||
editor = createVueEditor({
|
||||
element: el!,
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
leaf ? createLeafNode(component) : createBlockNode(component, options),
|
||||
],
|
||||
content,
|
||||
})
|
||||
return new Promise(r => setTimeout(r, 10))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
el = document.createElement('div')
|
||||
document.body.appendChild(el)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
el?.parentNode?.removeChild(el)
|
||||
el = null
|
||||
})
|
||||
|
||||
it('does not thrash the DOM when <node-view-content> is absent', async () => {
|
||||
await withEditor('<custom-block>Hello World</custom-block>', ComponentWithoutContent)
|
||||
|
||||
// Node view wrapper should mount
|
||||
expect(el!.querySelector('[data-node-view-wrapper]')).toBeTruthy()
|
||||
|
||||
// Without <node-view-content>, the contentDOMElement stays detached
|
||||
// (matching React's behavior). It exists but is not in the DOM tree.
|
||||
expect(el!.querySelector('[data-node-view-content]')).toBeFalsy()
|
||||
|
||||
// Verify the internal contentDOM is not null (fixes the thrashing bug)
|
||||
const wrapperDesc = (el!.querySelector('[data-node-view-wrapper]') as any)?.pmViewDesc
|
||||
expect(wrapperDesc?.spec?.contentDOM).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns null for leaf nodes', async () => {
|
||||
await withEditor('<custom-leaf />', LeafComponent, true)
|
||||
expect(el!.querySelector('[data-node-view-content]')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('works with <node-view-content> in the component', async () => {
|
||||
await withEditor('<custom-block>Hello World</custom-block>', ComponentWithContent)
|
||||
const content = el!.querySelector('[data-node-view-content]')
|
||||
expect(content).toBeTruthy()
|
||||
expect(content!.textContent).toContain('Hello World')
|
||||
})
|
||||
|
||||
it('adopts <node-view-content as="tbody"> as the contentDOM instead of nesting a wrapper div inside it', async () => {
|
||||
await withEditor('<custom-block>Hello World</custom-block>', ComponentWithTbodyContent)
|
||||
const tbody = el!.querySelector('tbody[data-node-view-content]')
|
||||
expect(tbody).toBeTruthy()
|
||||
// A <tbody> may only contain <tr> elements, so the placeholder wrapper div
|
||||
// must never be nested inside it.
|
||||
expect(tbody!.querySelector('[data-node-view-content-vue]')).toBeFalsy()
|
||||
expect(tbody!.children.length).toBe(0)
|
||||
expect(tbody!.textContent).toContain('Hello World')
|
||||
})
|
||||
|
||||
it('does not throw on destroy', async () => {
|
||||
await withEditor('<custom-block>Hello World</custom-block>', ComponentWithoutContent)
|
||||
expect(() => editor!.destroy()).not.toThrow()
|
||||
})
|
||||
|
||||
// Uses `<node-view-content>` as `contentDOM`; `contentDOMElementTag` only defines the fallback when none is rendered.
|
||||
it('ignores contentDOMElementTag when a real <node-view-content> is present', async () => {
|
||||
await withEditor('<custom-block>Hello World</custom-block>', ComponentWithContent, false, {
|
||||
contentDOMElementTag: 'custom-content-dom',
|
||||
})
|
||||
expect(el!.querySelector('custom-content-dom')).toBeFalsy()
|
||||
const content = el!.querySelector('[data-node-view-content]')
|
||||
expect(content).toBeTruthy()
|
||||
expect(content!.tagName.toLowerCase()).toBe('div')
|
||||
expect(content!.textContent).toContain('Hello World')
|
||||
})
|
||||
|
||||
it('use custom content dom element tag as the fallback when there is no <node-view-content>', async () => {
|
||||
await withEditor('<custom-block>Hello World</custom-block>', ComponentWithoutContent, false, {
|
||||
contentDOMElementTag: 'custom-content-dom',
|
||||
})
|
||||
// The fallback placeholder stays detached (see "does not thrash the DOM" above),
|
||||
// so we assert on the internal contentDOM rather than querying the live DOM.
|
||||
const wrapperDesc = (el!.querySelector('[data-node-view-wrapper]') as any)?.pmViewDesc
|
||||
const contentDOM = wrapperDesc?.spec?.contentDOM as HTMLElement | undefined
|
||||
expect(contentDOM).toBeTruthy()
|
||||
expect(contentDOM!.tagName.toLowerCase()).toBe('custom-content-dom')
|
||||
})
|
||||
})
|
||||
441
packages/vue/src/VueNodeViewRenderer.ts
Normal file
441
packages/vue/src/VueNodeViewRenderer.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
/* oslint-disable no-underscore-dangle */
|
||||
import type {
|
||||
DecorationWithType,
|
||||
NodeViewProps,
|
||||
NodeViewRenderer,
|
||||
NodeViewRendererOptions,
|
||||
NodeViewRendererProps,
|
||||
} from '@tiptap/core'
|
||||
import { isNodeViewSelected, NodeView } from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import type { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view'
|
||||
import type { Component, PropType, Ref } from 'vue'
|
||||
import { defineComponent, provide, ref } from 'vue'
|
||||
|
||||
import type { Editor } from './Editor.js'
|
||||
import { VueRenderer } from './VueRenderer.js'
|
||||
|
||||
export const nodeViewProps = {
|
||||
editor: {
|
||||
type: Object as PropType<NodeViewProps['editor']>,
|
||||
required: true as const,
|
||||
},
|
||||
node: {
|
||||
type: Object as PropType<NodeViewProps['node']>,
|
||||
required: true as const,
|
||||
},
|
||||
decorations: {
|
||||
type: Object as PropType<NodeViewProps['decorations']>,
|
||||
required: true as const,
|
||||
},
|
||||
selected: {
|
||||
type: Boolean as PropType<NodeViewProps['selected']>,
|
||||
required: true as const,
|
||||
},
|
||||
extension: {
|
||||
type: Object as PropType<NodeViewProps['extension']>,
|
||||
required: true as const,
|
||||
},
|
||||
getPos: {
|
||||
type: Function as PropType<NodeViewProps['getPos']>,
|
||||
required: true as const,
|
||||
},
|
||||
updateAttributes: {
|
||||
type: Function as PropType<NodeViewProps['updateAttributes']>,
|
||||
required: true as const,
|
||||
},
|
||||
deleteNode: {
|
||||
type: Function as PropType<NodeViewProps['deleteNode']>,
|
||||
required: true as const,
|
||||
},
|
||||
view: {
|
||||
type: Object as PropType<NodeViewProps['view']>,
|
||||
required: true as const,
|
||||
},
|
||||
innerDecorations: {
|
||||
type: Object as PropType<NodeViewProps['innerDecorations']>,
|
||||
required: true as const,
|
||||
},
|
||||
HTMLAttributes: {
|
||||
type: Object as PropType<NodeViewProps['HTMLAttributes']>,
|
||||
required: true as const,
|
||||
},
|
||||
}
|
||||
|
||||
export interface VueNodeViewRendererOptions extends NodeViewRendererOptions {
|
||||
update:
|
||||
| ((props: {
|
||||
oldNode: PMNode
|
||||
oldDecorations: readonly Decoration[]
|
||||
oldInnerDecorations: DecorationSource
|
||||
newNode: PMNode
|
||||
newDecorations: readonly Decoration[]
|
||||
innerDecorations: DecorationSource
|
||||
updateProps: () => void
|
||||
}) => boolean)
|
||||
| null
|
||||
}
|
||||
|
||||
class VueNodeView extends NodeView<Component, Editor, VueNodeViewRendererOptions> {
|
||||
renderer!: VueRenderer
|
||||
|
||||
decorationClasses!: Ref<string>
|
||||
|
||||
/**
|
||||
* The element that holds the rich-text content of the node.
|
||||
* Always created for non-leaf nodes to guarantee a valid contentDOM,
|
||||
* even when the user's component does not include a NodeViewContent.
|
||||
* This matches React's behavior and prevents ProseMirror from
|
||||
* thrashing the DOM when contentDOM is unexpectedly null.
|
||||
*
|
||||
* NOTE: must NOT have an initializer (= null), because class field
|
||||
* initializers run AFTER super() and would overwrite the value set
|
||||
* by mount() during the super() call.
|
||||
*/
|
||||
contentDOMElement!: HTMLElement | null
|
||||
|
||||
private currentPos: number | undefined
|
||||
|
||||
private cachedExtensionWithSyncedStorage: NodeViewProps['extension'] | null = null
|
||||
|
||||
constructor(
|
||||
component: Component,
|
||||
props: NodeViewRendererProps,
|
||||
options?: Partial<VueNodeViewRendererOptions>,
|
||||
) {
|
||||
super(component, props, options)
|
||||
|
||||
if (this.options.trackNodeViewPosition) {
|
||||
this.editor.on('update', this.handlePositionUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a proxy of the extension that redirects storage access to the editor's mutable storage.
|
||||
* This preserves the original prototype chain (instanceof checks, methods like configure/extend work).
|
||||
* Cached to avoid proxy creation on every update.
|
||||
*/
|
||||
get extensionWithSyncedStorage(): NodeViewProps['extension'] {
|
||||
if (!this.cachedExtensionWithSyncedStorage) {
|
||||
const editor = this.editor
|
||||
const extension = this.extension
|
||||
|
||||
this.cachedExtensionWithSyncedStorage = new Proxy(extension, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'storage') {
|
||||
return editor.storage[extension.name as keyof typeof editor.storage] ?? {}
|
||||
}
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return this.cachedExtensionWithSyncedStorage
|
||||
}
|
||||
|
||||
mount() {
|
||||
const props: Record<string, any> = {
|
||||
editor: this.editor,
|
||||
node: this.node,
|
||||
decorations: this.decorations as DecorationWithType[],
|
||||
innerDecorations: this.innerDecorations,
|
||||
view: this.view,
|
||||
selected: false,
|
||||
extension: this.extensionWithSyncedStorage,
|
||||
HTMLAttributes: this.HTMLAttributes,
|
||||
getPos: () => this.getPos(),
|
||||
updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
|
||||
deleteNode: () => this.deleteNode(),
|
||||
}
|
||||
|
||||
const mountProps = props as NodeViewProps
|
||||
|
||||
const onDragStart = this.onDragStart.bind(this)
|
||||
|
||||
this.decorationClasses = ref(this.getDecorationClasses())
|
||||
|
||||
const extendedComponent = defineComponent({
|
||||
extends: { ...this.component },
|
||||
props: Object.keys(props),
|
||||
template: (this.component as any).template,
|
||||
setup: reactiveProps => {
|
||||
provide('onDragStart', onDragStart)
|
||||
provide('decorationClasses', this.decorationClasses)
|
||||
provide('nodeViewContentRef', (el: HTMLElement | null) => {
|
||||
if (!el || el === this.contentDOMElement) return
|
||||
|
||||
// Adopt this stable element as contentDOM, preserving existing children.
|
||||
// Do not conditionally remount it; use v-show instead of v-if).
|
||||
if (this.contentDOMElement) {
|
||||
while (this.contentDOMElement.firstChild) {
|
||||
el.appendChild(this.contentDOMElement.firstChild)
|
||||
}
|
||||
}
|
||||
this.contentDOMElement = el
|
||||
})
|
||||
|
||||
return (this.component as any).setup?.(reactiveProps, {
|
||||
expose: () => undefined,
|
||||
})
|
||||
},
|
||||
// add support for scoped styles
|
||||
// @ts-ignore
|
||||
// oxlint-disable-next-line
|
||||
__scopeId: this.component.__scopeId,
|
||||
// add support for CSS Modules
|
||||
// @ts-ignore
|
||||
// oxlint-disable-next-line
|
||||
__cssModules: this.component.__cssModules,
|
||||
// add support for vue devtools
|
||||
// @ts-ignore
|
||||
// oxlint-disable-next-line
|
||||
__name: this.component.__name,
|
||||
// @ts-ignore
|
||||
// oxlint-disable-next-line
|
||||
__file: this.component.__file,
|
||||
})
|
||||
|
||||
this.handleSelectionUpdate = this.handleSelectionUpdate.bind(this)
|
||||
this.editor.on('selectionUpdate', this.handleSelectionUpdate)
|
||||
|
||||
this.currentPos = this.getPos()
|
||||
|
||||
if (!this.node.isLeaf) {
|
||||
// Create the content DOM element BEFORE rendering the Vue component,
|
||||
// so it is available immediately when ProseMirror accesses contentDOM
|
||||
// during the initial DOM build.
|
||||
if (this.options.contentDOMElementTag) {
|
||||
this.contentDOMElement = document.createElement(this.options.contentDOMElementTag)
|
||||
} else {
|
||||
this.contentDOMElement = document.createElement(this.node.isInline ? 'span' : 'div')
|
||||
}
|
||||
this.contentDOMElement.style.whiteSpace = 'inherit'
|
||||
// Use a distinct attribute to avoid clashing with the user's
|
||||
// <node-view-content> element (which carries data-node-view-content).
|
||||
// Matches React's data-node-view-content-react convention.
|
||||
this.contentDOMElement.dataset.nodeViewContentVue = ''
|
||||
}
|
||||
|
||||
this.renderer = new VueRenderer(extendedComponent, {
|
||||
editor: this.editor,
|
||||
props: mountProps,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires on editor updates when trackNodeViewPosition is enabled.
|
||||
* Detects position shifts where update() is NOT called.
|
||||
*/
|
||||
private handlePositionUpdate = () => {
|
||||
const newPos = this.getPos()
|
||||
if (typeof newPos !== 'number' || newPos === this.currentPos) {
|
||||
return
|
||||
}
|
||||
this.currentPos = newPos
|
||||
this.renderer.updateProps({ getPos: () => this.getPos() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the DOM element.
|
||||
* This is the element that will be used to display the node view.
|
||||
*/
|
||||
get dom() {
|
||||
if (!this.renderer.element || !this.renderer.element.hasAttribute('data-node-view-wrapper')) {
|
||||
throw Error('Please use the NodeViewWrapper component for your node view.')
|
||||
}
|
||||
|
||||
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() {
|
||||
if (this.node.isLeaf) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.contentDOMElement
|
||||
}
|
||||
|
||||
/**
|
||||
* On editor selection update, check if the node is selected.
|
||||
* If it is, call `selectNode`, otherwise call `deselectNode`.
|
||||
*/
|
||||
handleSelectionUpdate() {
|
||||
const pos = this.getPos()
|
||||
|
||||
if (typeof pos !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
const isSelected = isNodeViewSelected({
|
||||
selection: this.editor.state.selection,
|
||||
pos,
|
||||
nodeSize: this.node.nodeSize,
|
||||
selectedOnTextSelection: this.options.selectedOnTextSelection,
|
||||
})
|
||||
|
||||
if (isSelected) {
|
||||
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: PMNode,
|
||||
decorations: readonly Decoration[],
|
||||
innerDecorations: DecorationSource,
|
||||
): boolean {
|
||||
const rerenderComponent = (props?: Record<string, any>) => {
|
||||
this.decorationClasses.value = this.getDecorationClasses()
|
||||
this.renderer.updateProps(props)
|
||||
}
|
||||
|
||||
if (typeof this.options.update === 'function') {
|
||||
const oldNode = this.node
|
||||
const oldDecorations = this.decorations
|
||||
const oldInnerDecorations = this.innerDecorations
|
||||
|
||||
this.node = node
|
||||
this.decorations = decorations
|
||||
this.innerDecorations = innerDecorations
|
||||
|
||||
return this.options.update({
|
||||
oldNode,
|
||||
oldDecorations,
|
||||
newNode: node,
|
||||
newDecorations: decorations,
|
||||
oldInnerDecorations,
|
||||
innerDecorations,
|
||||
updateProps: () =>
|
||||
rerenderComponent({
|
||||
node,
|
||||
decorations,
|
||||
innerDecorations,
|
||||
extension: this.extensionWithSyncedStorage,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (node.type !== this.node.type) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nodeChanged = node !== this.node
|
||||
|
||||
// Node reference unchanged — only decorations may have changed.
|
||||
// ProseMirror renders decorations independently on the contentDOM,
|
||||
// and the getPos closure (bound in mount()) calls through to
|
||||
// ProseMirror's position function at call time, so it is always
|
||||
// current. Update internal refs, refresh decoration classes for
|
||||
// the wrapper component, and skip the Vue re-render.
|
||||
if (!nodeChanged) {
|
||||
this.node = node
|
||||
this.decorations = decorations
|
||||
this.innerDecorations = innerDecorations
|
||||
this.decorationClasses.value = this.getDecorationClasses()
|
||||
return true
|
||||
}
|
||||
|
||||
this.node = node
|
||||
this.decorations = decorations
|
||||
this.innerDecorations = innerDecorations
|
||||
this.currentPos = this.getPos()
|
||||
|
||||
const extraProps: Record<string, any> = {
|
||||
node,
|
||||
decorations,
|
||||
innerDecorations,
|
||||
extension: this.extensionWithSyncedStorage,
|
||||
}
|
||||
|
||||
if (this.options.trackNodeViewPosition) {
|
||||
extraProps.getPos = () => this.getPos()
|
||||
}
|
||||
|
||||
rerenderComponent(extraProps)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the node.
|
||||
* Add the `selected` prop and the `ProseMirror-selectednode` class.
|
||||
*/
|
||||
selectNode() {
|
||||
this.renderer.updateProps({
|
||||
selected: true,
|
||||
})
|
||||
if (this.renderer.element) {
|
||||
this.renderer.element.classList.add('ProseMirror-selectednode')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect the node.
|
||||
* Remove the `selected` prop and the `ProseMirror-selectednode` class.
|
||||
*/
|
||||
deselectNode() {
|
||||
this.renderer.updateProps({
|
||||
selected: false,
|
||||
})
|
||||
if (this.renderer.element) {
|
||||
this.renderer.element.classList.remove('ProseMirror-selectednode')
|
||||
}
|
||||
}
|
||||
|
||||
getDecorationClasses() {
|
||||
return (
|
||||
this.decorations
|
||||
// @ts-ignore
|
||||
.flatMap(item => item.type.attrs.class)
|
||||
.join(' ')
|
||||
)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.renderer.destroy()
|
||||
this.editor.off('selectionUpdate', this.handleSelectionUpdate)
|
||||
|
||||
if (this.options.trackNodeViewPosition) {
|
||||
this.editor.off('update', this.handlePositionUpdate)
|
||||
}
|
||||
|
||||
this.contentDOMElement = null
|
||||
}
|
||||
}
|
||||
|
||||
export function VueNodeViewRenderer(
|
||||
component: Component<NodeViewProps>,
|
||||
options?: Partial<VueNodeViewRendererOptions>,
|
||||
): NodeViewRenderer {
|
||||
return props => {
|
||||
// try to get the parent component
|
||||
// this is important for vue devtools to show the component hierarchy correctly
|
||||
// maybe it’s `undefined` because <editor-content> isn’t rendered yet
|
||||
if (!(props.editor as Editor).contentComponent) {
|
||||
return {} as unknown as ProseMirrorNodeView
|
||||
}
|
||||
// check for class-component and normalize if neccessary
|
||||
const normalizedComponent =
|
||||
typeof component === 'function' && '__vccOpts' in component
|
||||
? (component.__vccOpts as Component)
|
||||
: component
|
||||
|
||||
return new VueNodeView(normalizedComponent, props, options)
|
||||
}
|
||||
}
|
||||
178
packages/vue/src/VueRenderer.test.ts
Normal file
178
packages/vue/src/VueRenderer.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import { VueRenderer } from './VueRenderer.js'
|
||||
|
||||
describe('VueRenderer', () => {
|
||||
let editor: Editor | null = null
|
||||
let editorElement: HTMLElement | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
editorElement = document.createElement('div')
|
||||
document.body.appendChild(editorElement)
|
||||
|
||||
editor = new Editor({
|
||||
element: editorElement,
|
||||
extensions: [Document, Paragraph, Text],
|
||||
content: '<p>Hello World</p>',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (editor) {
|
||||
editor.destroy()
|
||||
editor = null
|
||||
}
|
||||
if (editorElement && editorElement.parentNode) {
|
||||
editorElement.parentNode.removeChild(editorElement)
|
||||
editorElement = null
|
||||
}
|
||||
})
|
||||
|
||||
it('should initialize with destroyed flag set to false', () => {
|
||||
const TestComponent = defineComponent({
|
||||
name: 'TestComponent',
|
||||
template: '<div>Test</div>',
|
||||
})
|
||||
|
||||
const renderer = new VueRenderer(TestComponent, {
|
||||
editor: editor!,
|
||||
props: {},
|
||||
})
|
||||
|
||||
expect(renderer.destroyed).toBe(false)
|
||||
|
||||
renderer.destroy()
|
||||
})
|
||||
|
||||
it('should set destroyed flag to true after destroy', () => {
|
||||
const TestComponent = defineComponent({
|
||||
name: 'TestComponent',
|
||||
template: '<div>Test</div>',
|
||||
})
|
||||
|
||||
const renderer = new VueRenderer(TestComponent, {
|
||||
editor: editor!,
|
||||
props: {},
|
||||
})
|
||||
|
||||
expect(renderer.destroyed).toBe(false)
|
||||
renderer.destroy()
|
||||
expect(renderer.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow multiple destroy calls without errors', () => {
|
||||
const TestComponent = defineComponent({
|
||||
name: 'TestComponent',
|
||||
template: '<div>Test</div>',
|
||||
})
|
||||
|
||||
const renderer = new VueRenderer(TestComponent, {
|
||||
editor: editor!,
|
||||
props: {},
|
||||
})
|
||||
|
||||
expect(() => {
|
||||
renderer.destroy()
|
||||
renderer.destroy()
|
||||
renderer.destroy()
|
||||
}).not.toThrow()
|
||||
|
||||
expect(renderer.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
it('should not update props when destroyed', () => {
|
||||
const TestComponent = defineComponent({
|
||||
name: 'TestComponent',
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
template: '<div>{{ value }}</div>',
|
||||
})
|
||||
|
||||
const renderer = new VueRenderer(TestComponent, {
|
||||
editor: editor!,
|
||||
props: { value: 'initial' },
|
||||
})
|
||||
|
||||
expect(renderer.props.value).toBe('initial')
|
||||
|
||||
renderer.destroy()
|
||||
renderer.updateProps({ value: 'updated' })
|
||||
|
||||
// Props should not be updated after destroy
|
||||
expect(renderer.props.value).toBe('initial')
|
||||
})
|
||||
|
||||
it('should not render when destroyed', () => {
|
||||
const TestComponent = defineComponent({
|
||||
name: 'TestComponent',
|
||||
template: '<div>Test</div>',
|
||||
})
|
||||
|
||||
const renderer = new VueRenderer(TestComponent, {
|
||||
editor: editor!,
|
||||
props: {},
|
||||
})
|
||||
|
||||
const originalComponent = renderer.renderedComponent
|
||||
|
||||
renderer.destroy()
|
||||
const resultAfterDestroy = renderer.renderComponent()
|
||||
|
||||
// Should return the same renderedComponent without re-rendering
|
||||
expect(resultAfterDestroy).toBe(originalComponent)
|
||||
})
|
||||
|
||||
it('should handle props updates before destroy', () => {
|
||||
const TestComponent = defineComponent({
|
||||
name: 'TestComponent',
|
||||
props: {
|
||||
count: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
template: '<div>{{ count }}</div>',
|
||||
})
|
||||
|
||||
const renderer = new VueRenderer(TestComponent, {
|
||||
editor: editor!,
|
||||
props: { count: 0 },
|
||||
})
|
||||
|
||||
expect(renderer.props.count).toBe(0)
|
||||
|
||||
renderer.updateProps({ count: 5 })
|
||||
expect(renderer.props.count).toBe(5)
|
||||
|
||||
renderer.updateProps({ count: 10 })
|
||||
expect(renderer.props.count).toBe(10)
|
||||
|
||||
renderer.destroy()
|
||||
})
|
||||
|
||||
it('should create and render element', () => {
|
||||
const TestComponent = defineComponent({
|
||||
name: 'TestComponent',
|
||||
template: '<div class="test-class">Content</div>',
|
||||
})
|
||||
|
||||
const renderer = new VueRenderer(TestComponent, {
|
||||
editor: editor!,
|
||||
props: {},
|
||||
})
|
||||
|
||||
expect(renderer.element).toBeTruthy()
|
||||
expect(renderer.element?.textContent).toBe('Content')
|
||||
|
||||
renderer.destroy()
|
||||
})
|
||||
})
|
||||
104
packages/vue/src/VueRenderer.ts
Normal file
104
packages/vue/src/VueRenderer.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { Component, DefineComponent } from 'vue'
|
||||
import { h, markRaw, reactive, render } from 'vue'
|
||||
|
||||
import type { Editor as ExtendedEditor } from './Editor.js'
|
||||
|
||||
export interface VueRendererOptions {
|
||||
editor: Editor
|
||||
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.
|
||||
*/
|
||||
export class VueRenderer {
|
||||
renderedComponent!: RenderedComponent
|
||||
|
||||
editor: ExtendedEditor
|
||||
|
||||
component: Component
|
||||
|
||||
el: Element | null
|
||||
|
||||
props: Record<string, any>
|
||||
|
||||
/**
|
||||
* Flag to track if the renderer has been destroyed, preventing queued or asynchronous renders from executing after teardown.
|
||||
*/
|
||||
destroyed = false
|
||||
|
||||
constructor(component: Component, { props = {}, editor }: VueRendererOptions) {
|
||||
this.editor = editor as ExtendedEditor
|
||||
this.component = markRaw(component)
|
||||
this.el = document.createElement('div')
|
||||
this.props = reactive(props)
|
||||
this.renderedComponent = this.renderComponent()
|
||||
}
|
||||
|
||||
get element(): Element | null {
|
||||
return this.renderedComponent.el
|
||||
}
|
||||
|
||||
get ref(): any {
|
||||
// Composition API
|
||||
if (this.renderedComponent.vNode?.component?.exposed) {
|
||||
return this.renderedComponent.vNode.component.exposed
|
||||
}
|
||||
// Option API
|
||||
return this.renderedComponent.vNode?.component?.proxy
|
||||
}
|
||||
|
||||
renderComponent() {
|
||||
if (this.destroyed) {
|
||||
return this.renderedComponent
|
||||
}
|
||||
|
||||
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 {
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
this.props[key] = value
|
||||
})
|
||||
this.renderComponent()
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.destroyed = true
|
||||
this.renderedComponent.destroy()
|
||||
}
|
||||
}
|
||||
498
packages/vue/src/VueWidgetRenderer.test.ts
Normal file
498
packages/vue/src/VueWidgetRenderer.test.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import { type AnyExtension, Extension } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { UndoRedo } from '@tiptap/extensions'
|
||||
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
import { Editor } from './Editor.js'
|
||||
import { VueWidgetRenderer } from './VueWidgetRenderer.js'
|
||||
|
||||
let renderCount = 0
|
||||
let unmountCount = 0
|
||||
|
||||
const Counter = defineComponent({
|
||||
name: 'Counter',
|
||||
inheritAttrs: false,
|
||||
props: { index: { type: Number, default: 0 } },
|
||||
data() {
|
||||
return { count: 0 }
|
||||
},
|
||||
unmounted() {
|
||||
unmountCount += 1
|
||||
},
|
||||
render() {
|
||||
renderCount += 1
|
||||
return h('button', { class: 'counter' }, `${this.index}:${this.count}`)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* A widget per paragraph, keyed by paragraph index.
|
||||
*
|
||||
* NOTE: Index-based keys churn when paragraphs are inserted/removed. This is
|
||||
* intentional for testing re-keying behavior. In production, use stable domain
|
||||
* keys (e.g. `paragraph-${node.attrs.id}`) so the widget DOM and component
|
||||
* state are preserved across edits.
|
||||
*/
|
||||
function paragraphWidgets() {
|
||||
return Extension.create({
|
||||
name: 'paragraphWidgets',
|
||||
addDecorations() {
|
||||
return {
|
||||
create: ({ editor, state }) => {
|
||||
const decorations: any[] = []
|
||||
let index = 0
|
||||
|
||||
state.doc.forEach((node, offset) => {
|
||||
if (node.type.name !== 'paragraph') {
|
||||
return
|
||||
}
|
||||
decorations.push(
|
||||
VueWidgetRenderer(Counter, {
|
||||
editor,
|
||||
pos: offset + node.nodeSize - 1,
|
||||
key: `p-${index}`,
|
||||
props: { index },
|
||||
side: 1,
|
||||
}),
|
||||
)
|
||||
index += 1
|
||||
})
|
||||
|
||||
return decorations
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Same as Counter but without inheritAttrs: false. Used to verify the renderer
|
||||
// auto-declares editor/getPos so they never fall through to the DOM.
|
||||
const CounterNoInheritAttrs = defineComponent({
|
||||
name: 'CounterNoInheritAttrs',
|
||||
props: { index: { type: Number, default: 0 } },
|
||||
data() {
|
||||
return { count: 0 }
|
||||
},
|
||||
render() {
|
||||
return h('button', { class: 'counter-no-inherit' }, `${this.index}:${this.count}`)
|
||||
},
|
||||
})
|
||||
|
||||
// Records the props each render received, so a test can assert the widget never
|
||||
// loses `editor` / `getPos` during the two-phase prop update.
|
||||
const propLog: Array<{ hasEditor: boolean; hasGetPos: boolean }> = []
|
||||
|
||||
const PropSpy = defineComponent({
|
||||
name: 'PropSpy',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
size: { type: Number, default: 0 },
|
||||
editor: { type: Object, default: null },
|
||||
getPos: { type: Function, default: null },
|
||||
},
|
||||
render() {
|
||||
propLog.push({ hasEditor: !!this.editor, hasGetPos: typeof this.getPos === 'function' })
|
||||
return h('span', { class: 'spy' })
|
||||
},
|
||||
})
|
||||
|
||||
// A single stable-keyed widget whose `size` prop changes on every edit, so the
|
||||
// pre-render `updateProps` (which pushes only user props, not editor/getPos)
|
||||
// actually fires.
|
||||
function spyWidget() {
|
||||
return Extension.create({
|
||||
name: 'spyWidget',
|
||||
addDecorations() {
|
||||
return {
|
||||
create: ({ editor, state }) => {
|
||||
const first = state.doc.firstChild
|
||||
|
||||
if (!first) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
VueWidgetRenderer(PropSpy, {
|
||||
editor,
|
||||
pos: first.nodeSize - 1,
|
||||
key: 'spy-stable',
|
||||
props: { size: state.doc.content.size },
|
||||
side: 1,
|
||||
}),
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Widget props queued during a transaction are pushed on the next microtask,
|
||||
// so `state.apply` stays pure.
|
||||
async function flushWidgetProps() {
|
||||
await Promise.resolve()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('VueWidgetRenderer', () => {
|
||||
let editor: Editor | null = null
|
||||
let el: HTMLElement | null = null
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
editor = null
|
||||
if (el?.parentNode) {
|
||||
el.parentNode.removeChild(el)
|
||||
}
|
||||
el = null
|
||||
renderCount = 0
|
||||
unmountCount = 0
|
||||
})
|
||||
|
||||
function mount(content: string, extraExtensions: AnyExtension[] = []) {
|
||||
el = document.createElement('div')
|
||||
document.body.appendChild(el)
|
||||
editor = new Editor({
|
||||
element: el,
|
||||
extensions: [Document, Paragraph, Text, paragraphWidgets(), ...extraExtensions],
|
||||
content,
|
||||
})
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
function mountWithUndo(content: string) {
|
||||
return mount(content, [UndoRedo])
|
||||
}
|
||||
|
||||
it('renders a widget per paragraph', () => {
|
||||
mount('<p>aaa</p><p>bbb</p>')
|
||||
|
||||
expect(el!.querySelectorAll('.counter').length).toBe(2)
|
||||
})
|
||||
|
||||
it('passes ProseMirror widget options through', () => {
|
||||
const stopEvent = vi.fn(() => true)
|
||||
const destroy = vi.fn()
|
||||
const extension = Extension.create({
|
||||
name: 'widgetOptions',
|
||||
addDecorations: () => ({
|
||||
create: ({ editor }) => [
|
||||
VueWidgetRenderer(Counter, {
|
||||
editor,
|
||||
pos: 1,
|
||||
key: 'options',
|
||||
side: -1,
|
||||
relaxedSide: true,
|
||||
ignoreSelection: true,
|
||||
stopEvent,
|
||||
destroy,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
mount('<p>a</p>', [extension])
|
||||
const decorationState = editor!.state.plugins
|
||||
.find(plugin => plugin.props.decorations)
|
||||
?.getState(editor!.state) as
|
||||
| { mergedDecorationSet?: { find: () => Array<{ spec: Record<string, unknown> }> } }
|
||||
| undefined
|
||||
const widget = decorationState?.mergedDecorationSet?.find()[0]
|
||||
|
||||
expect(widget?.spec).toMatchObject({
|
||||
side: -1,
|
||||
relaxedSide: true,
|
||||
ignoreSelection: true,
|
||||
stopEvent,
|
||||
destroy: expect.any(Function),
|
||||
})
|
||||
|
||||
editor!.destroy()
|
||||
expect(destroy).not.toHaveBeenCalled()
|
||||
editor = null
|
||||
})
|
||||
|
||||
it('does not re-render widgets when props are unchanged on a transaction', () => {
|
||||
mount('<p>aaa</p><p>bbb</p>')
|
||||
|
||||
const afterMount = renderCount
|
||||
|
||||
// Typing does not change any widget's `index` prop — the guard must avoid
|
||||
// re-rendering every widget on every transaction (the cause of the hang).
|
||||
editor!.commands.insertContentAt(2, 'X')
|
||||
editor!.commands.insertContentAt(2, 'Y')
|
||||
editor!.commands.insertContentAt(2, 'Z')
|
||||
|
||||
expect(renderCount).toBe(afterMount)
|
||||
})
|
||||
|
||||
it('does not push props while a transaction is being applied', async () => {
|
||||
propLog.length = 0
|
||||
mount('<p>aaa</p>', [spyWidget()])
|
||||
|
||||
const afterMount = propLog.length
|
||||
|
||||
// `create()` runs inside `state.apply`, which must stay pure. VueRenderer
|
||||
// renders synchronously on updateProps, so the push has to wait.
|
||||
editor!.commands.insertContentAt(2, 'X')
|
||||
expect(propLog.length).toBe(afterMount)
|
||||
|
||||
await flushWidgetProps()
|
||||
expect(propLog.length).toBeGreaterThan(afterMount)
|
||||
})
|
||||
|
||||
it('never drops editor/getPos from widget props across updates', async () => {
|
||||
propLog.length = 0
|
||||
mount('<p>aaa</p>', [spyWidget()])
|
||||
|
||||
expect(propLog.length).toBeGreaterThan(0)
|
||||
|
||||
// Each insert changes the spy's `size` prop, which queues a deferred
|
||||
// updateProps pushing only user props. Because updateProps merges,
|
||||
// editor/getPos pushed by the previous render must survive.
|
||||
editor!.commands.insertContentAt(2, 'X')
|
||||
editor!.commands.insertContentAt(2, 'Y')
|
||||
await flushWidgetProps()
|
||||
|
||||
expect(propLog.length).toBeGreaterThan(1)
|
||||
expect(propLog.every(entry => entry.hasEditor && entry.hasGetPos)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps every widget mounted when keys are reassigned by a split', () => {
|
||||
mount('<p>aaa</p><p>bbb</p>')
|
||||
expect(el!.querySelectorAll('.counter').length).toBe(2)
|
||||
|
||||
editor!.chain().setTextSelection(2).splitBlock().run()
|
||||
|
||||
expect(editor!.state.doc.childCount).toBe(3)
|
||||
expect(el!.querySelectorAll('.counter').length).toBe(3)
|
||||
})
|
||||
|
||||
it('removes the widget when a paragraph is removed', () => {
|
||||
mount('<p>aaa</p><p>bbb</p>')
|
||||
expect(el!.querySelectorAll('.counter').length).toBe(2)
|
||||
|
||||
editor!.chain().setTextSelection(6).joinBackward().run()
|
||||
|
||||
expect(editor!.state.doc.childCount).toBe(1)
|
||||
expect(el!.querySelectorAll('.counter').length).toBe(1)
|
||||
})
|
||||
|
||||
it('removes stale widgets when setContent replaces the whole document', () => {
|
||||
mount('<p>a</p><p>b</p><p>c</p>')
|
||||
expect(el!.querySelectorAll('.counter').length).toBe(3)
|
||||
|
||||
editor!.commands.setContent('<p>only</p>')
|
||||
|
||||
expect(editor!.state.doc.childCount).toBe(1)
|
||||
expect(el!.querySelectorAll('.counter').length).toBe(1)
|
||||
})
|
||||
|
||||
it('destroys all active widget components when the editor is destroyed', () => {
|
||||
mount('<p>a</p><p>b</p>')
|
||||
expect(el!.querySelectorAll('.counter').length).toBe(2)
|
||||
|
||||
const local = editor!
|
||||
const prevUnmount = unmountCount
|
||||
|
||||
local.destroy()
|
||||
|
||||
expect(unmountCount).toBe(prevUnmount + 2)
|
||||
})
|
||||
|
||||
it('does not leak components after undo of a paragraph split', () => {
|
||||
mountWithUndo('<p>a</p><p>b</p>')
|
||||
expect(el!.querySelectorAll('.counter').length).toBe(2)
|
||||
const prevUnmount = unmountCount
|
||||
|
||||
// Split first paragraph — 3 widgets.
|
||||
editor!.chain().setTextSelection(2).splitBlock().run()
|
||||
expect(editor!.state.doc.childCount).toBe(3)
|
||||
expect(el!.querySelectorAll('.counter').length).toBe(3)
|
||||
|
||||
// Undo removes the split-created paragraph, which destroys its widget.
|
||||
// The 1 unmount is correct cleanup — not a leak.
|
||||
editor!.commands.undo()
|
||||
expect(editor!.state.doc.childCount).toBe(2)
|
||||
expect(el!.querySelectorAll('.counter').length).toBe(2)
|
||||
expect(unmountCount).toBe(prevUnmount + 1)
|
||||
})
|
||||
|
||||
it('does not leak editor/getPos as DOM attributes without inheritAttrs: false', () => {
|
||||
const extension = Extension.create({
|
||||
name: 'noInheritAttrsWidgets',
|
||||
addDecorations() {
|
||||
return {
|
||||
create: ({ editor, state }) => {
|
||||
const decorations: any[] = []
|
||||
let index = 0
|
||||
|
||||
state.doc.forEach((node, offset) => {
|
||||
if (node.type.name !== 'paragraph') {
|
||||
return
|
||||
}
|
||||
decorations.push(
|
||||
VueWidgetRenderer(CounterNoInheritAttrs, {
|
||||
editor,
|
||||
pos: offset + node.nodeSize - 1,
|
||||
key: `no-inherit-${index}`,
|
||||
props: { index },
|
||||
side: 1,
|
||||
}),
|
||||
)
|
||||
index += 1
|
||||
})
|
||||
|
||||
return decorations
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
mount('<p>aaa</p><p>bbb</p>', [extension])
|
||||
|
||||
const buttons = el!.querySelectorAll('.counter-no-inherit')
|
||||
expect(buttons.length).toBe(2)
|
||||
|
||||
buttons.forEach(button => {
|
||||
expect(button.hasAttribute('editor')).toBe(false)
|
||||
expect(button.hasAttribute('getpos')).toBe(false)
|
||||
expect(button.hasAttribute('getPos')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the component prop options: defaults and Boolean casting', () => {
|
||||
const seen: Array<{ label: string; flag: boolean }> = []
|
||||
const Typed = defineComponent({
|
||||
name: 'Typed',
|
||||
props: {
|
||||
label: { type: String, default: 'FALLBACK' },
|
||||
flag: { type: Boolean, default: false },
|
||||
},
|
||||
render() {
|
||||
seen.push({ label: this.label, flag: this.flag })
|
||||
return h('span', { class: 'typed' }, this.label)
|
||||
},
|
||||
})
|
||||
|
||||
mount('<p>a</p>', [
|
||||
Extension.create({
|
||||
name: 'typedWidget',
|
||||
addDecorations: () => ({
|
||||
create: ({ editor }) => [
|
||||
VueWidgetRenderer(Typed, {
|
||||
editor,
|
||||
pos: 1,
|
||||
key: 'typed',
|
||||
// `undefined` must fall back to the default, `''` must cast to true.
|
||||
props: { label: undefined, flag: '' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
expect(seen[0]).toEqual({ label: 'FALLBACK', flag: true })
|
||||
})
|
||||
|
||||
it('renders a plain functional component', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const Functional = (props: any) => h('span', { class: 'functional' }, String(props.label))
|
||||
|
||||
mount('<p>a</p>', [
|
||||
Extension.create({
|
||||
name: 'functionalWidget',
|
||||
addDecorations: () => ({
|
||||
create: ({ editor }) => [
|
||||
VueWidgetRenderer(Functional, {
|
||||
editor,
|
||||
pos: 1,
|
||||
key: 'functional',
|
||||
props: { label: 'hi' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
const widget = el!.querySelector('.functional')
|
||||
|
||||
expect(widget?.textContent).toBe('hi')
|
||||
expect(warn).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('missing template or render function'),
|
||||
expect.anything(),
|
||||
)
|
||||
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps an array props declaration on a functional component', () => {
|
||||
const seen: Array<{ label: string; hasEditor: boolean }> = []
|
||||
const Functional: any = (props: any) => {
|
||||
seen.push({ label: props.label, hasEditor: !!props.editor })
|
||||
return h('span', { class: 'functional-array' })
|
||||
}
|
||||
|
||||
Functional.props = ['label']
|
||||
|
||||
mount('<p>a</p>', [
|
||||
Extension.create({
|
||||
name: 'functionalArrayWidget',
|
||||
addDecorations: () => ({
|
||||
create: ({ editor }) => [
|
||||
VueWidgetRenderer(Functional, {
|
||||
editor,
|
||||
pos: 1,
|
||||
key: 'functional-array',
|
||||
props: { label: 'hi' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
expect(seen[0]).toEqual({ label: 'hi', hasEditor: true })
|
||||
|
||||
// editor/getPos are declared as props, so they never fall through to the DOM.
|
||||
const widget = el!.querySelector('.functional-array')!
|
||||
expect(widget.hasAttribute('editor')).toBe(false)
|
||||
expect(widget.hasAttribute('getpos')).toBe(false)
|
||||
})
|
||||
|
||||
it('gives the component setup an emit function', () => {
|
||||
const emitted: string[] = []
|
||||
const Emitter = defineComponent({
|
||||
name: 'Emitter',
|
||||
emits: ['ready'],
|
||||
setup(_props, { emit }) {
|
||||
emit('ready')
|
||||
return () => h('span', { class: 'emitter' })
|
||||
},
|
||||
})
|
||||
|
||||
mount('<p>a</p>', [
|
||||
Extension.create({
|
||||
name: 'emitterWidget',
|
||||
addDecorations: () => ({
|
||||
create: ({ editor }) => [
|
||||
VueWidgetRenderer(Emitter, {
|
||||
editor,
|
||||
pos: 1,
|
||||
key: 'emitter',
|
||||
props: { onReady: () => emitted.push('ready') },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
expect(el!.querySelectorAll('.emitter').length).toBe(1)
|
||||
expect(emitted).toEqual(['ready'])
|
||||
})
|
||||
|
||||
// Duplicate widget keys intentionally not tested here — ProseMirror's view
|
||||
// crashes when it encounters same-key widgets, and the type-level contract
|
||||
// ("keys must be unique") is documented on WidgetDecoration.key.
|
||||
})
|
||||
120
packages/vue/src/VueWidgetRenderer.ts
Normal file
120
packages/vue/src/VueWidgetRenderer.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { createWidgetDecoration } from '@tiptap/core'
|
||||
import type { Editor, WidgetDecoration, WidgetDecorationOptions } from '@tiptap/core'
|
||||
import type { Component, FunctionalComponent, SetupContext } from 'vue'
|
||||
import { defineComponent, markRaw } from 'vue'
|
||||
|
||||
import { undeclaredWidgetProps } from './utils/undeclaredWidgetProps.js'
|
||||
import { wrapFunctionalWidget } from './utils/wrapFunctionalWidget.js'
|
||||
import { VueRenderer } from './VueRenderer.js'
|
||||
|
||||
/**
|
||||
* Props every widget-decoration component receives in addition to the props you
|
||||
* pass through `VueWidgetRenderer`.
|
||||
*/
|
||||
export interface VueWidgetDecorationProps {
|
||||
editor: Editor
|
||||
getPos: () => number | undefined
|
||||
}
|
||||
|
||||
export interface VueWidgetRendererOptions<
|
||||
P extends Record<string, any> = object,
|
||||
> extends WidgetDecorationOptions {
|
||||
/**
|
||||
* The editor instance.
|
||||
*/
|
||||
editor: Editor
|
||||
/**
|
||||
* The document position the widget is rendered at.
|
||||
*/
|
||||
pos: number
|
||||
/**
|
||||
* A stable, position-independent identifier for the widget.
|
||||
* Reusing the same key keeps the component mounted across re-renders.
|
||||
* Good: `comment-${id}`. Bad: paragraph index or document position.
|
||||
*/
|
||||
key: string
|
||||
/**
|
||||
* Props passed to the component (merged with {@link VueWidgetDecorationProps}).
|
||||
* The component must have a single root element.
|
||||
*
|
||||
* @remarks Props are deeply wrapped in `reactive()` by `VueRenderer`. The
|
||||
* `editor` is protected with `markRaw`; any other non-reactive objects you
|
||||
* pass (ProseMirror nodes, views, editor refs, ...) are not. Mark them raw
|
||||
* yourself when reactivity would cause recursion or unwanted tracking.
|
||||
*/
|
||||
props?: P
|
||||
}
|
||||
|
||||
const WIDGET_CACHE = Symbol('tiptapVueWidgetCache')
|
||||
|
||||
/**
|
||||
* Renders a Vue component into a ProseMirror widget decoration.
|
||||
* Reuses Tiptap's `VueRenderer` so the component shares the editor's app
|
||||
* context (provide/inject works as usual). Use a stable `key` for stateful
|
||||
* widgets. The component must render a single root element.
|
||||
* @example
|
||||
* addDecorations() {
|
||||
* return {
|
||||
* create: ({ editor, state }) =>
|
||||
* findMatches(state.doc).map(match =>
|
||||
* VueWidgetRenderer(MyWidget, {
|
||||
* editor, pos: match.pos, key: `match-${match.id}`,
|
||||
* props: { label: match.label },
|
||||
* }),
|
||||
* ),
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export function VueWidgetRenderer<P extends Record<string, any> = object>(
|
||||
component: Component,
|
||||
options: VueWidgetRendererOptions<P>,
|
||||
): WidgetDecoration {
|
||||
const { editor, props = {} as P } = options
|
||||
|
||||
// A functional component is its own render function, so it cannot go through
|
||||
// the options wrapper below.
|
||||
const wrappedComponent = isFunctionalComponent(component)
|
||||
? wrapFunctionalWidget(component, props)
|
||||
: buildOptionsWidget(component, props)
|
||||
|
||||
return createWidgetDecoration<VueRenderer>({
|
||||
// Forwards editor, pos, key and the ProseMirror widget options unchanged.
|
||||
...options,
|
||||
props,
|
||||
cacheKey: WIDGET_CACHE,
|
||||
// VueRenderer wraps props in reactive(), deeply proxying every object.
|
||||
// `editor` is markRaw so proxying its view/DOM graph does not crash.
|
||||
// User props are NOT auto-protected: markRaw any ProseMirror nodes,
|
||||
// views, editor refs, or other objects reactivity would harm.
|
||||
context: getPos => ({ editor: markRaw(editor), getPos }),
|
||||
create: renderProps => new VueRenderer(wrappedComponent, { editor, props: renderProps }),
|
||||
materialize: renderer => renderer.element as HTMLElement,
|
||||
})
|
||||
}
|
||||
|
||||
function isFunctionalComponent(component: Component): component is FunctionalComponent {
|
||||
return typeof component === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an object component so the widget props are declared on it. Everything
|
||||
* Vue reads off the original options has to be carried over by hand, because
|
||||
* `extends` alone does not apply to `template`, `setup` or the compiler keys.
|
||||
*/
|
||||
function buildOptionsWidget(component: Component, props: Record<string, any>) {
|
||||
return defineComponent({
|
||||
extends: { ...(component as any) },
|
||||
// Only declare what the component does not declare itself. Redeclaring a
|
||||
// prop here would replace its type, default and validator with an empty one.
|
||||
props: undeclaredWidgetProps(component, props),
|
||||
template: (component as any).template,
|
||||
// Vue only calls the outermost `setup`, so run the component's own setup and
|
||||
// forward the full context, otherwise `emit`, `slots` and `attrs` are missing.
|
||||
setup: (reactiveProps: any, context: SetupContext) =>
|
||||
(component as any).setup?.(reactiveProps, context),
|
||||
__scopeId: (component as any).__scopeId,
|
||||
__cssModules: (component as any).__cssModules,
|
||||
__name: (component as any).__name,
|
||||
__file: (component as any).__file,
|
||||
})
|
||||
}
|
||||
10
packages/vue/src/index.ts
Normal file
10
packages/vue/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { Editor } from './Editor.js'
|
||||
export * from './EditorContent.js'
|
||||
export * from './NodeViewContent.js'
|
||||
export * from './NodeViewWrapper.js'
|
||||
export * from './useEditor.js'
|
||||
export * from './VueMarkViewRenderer.js'
|
||||
export * from './VueNodeViewRenderer.js'
|
||||
export * from './VueRenderer.js'
|
||||
export * from './VueWidgetRenderer.js'
|
||||
export * from '@tiptap/core'
|
||||
110
packages/vue/src/menus/BubbleMenu.ts
Normal file
110
packages/vue/src/menus/BubbleMenu.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu'
|
||||
import { BubbleMenuPlugin } from '@tiptap/extension-bubble-menu'
|
||||
import { PluginKey } from '@tiptap/pm/state'
|
||||
import type { PropType } from 'vue'
|
||||
import { defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
export const BubbleMenu = defineComponent({
|
||||
name: 'BubbleMenu',
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
pluginKey: {
|
||||
type: [String, Object] as PropType<BubbleMenuPluginProps['pluginKey']>,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
editor: {
|
||||
type: Object as PropType<BubbleMenuPluginProps['editor']>,
|
||||
required: true,
|
||||
},
|
||||
|
||||
updateDelay: {
|
||||
type: Number as PropType<BubbleMenuPluginProps['updateDelay']>,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
resizeDelay: {
|
||||
type: Number as PropType<BubbleMenuPluginProps['resizeDelay']>,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
options: {
|
||||
type: Object as PropType<BubbleMenuPluginProps['options']>,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
appendTo: {
|
||||
type: [Object, Function] as PropType<BubbleMenuPluginProps['appendTo']>,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
shouldShow: {
|
||||
type: Function as PropType<Exclude<Required<BubbleMenuPluginProps>['shouldShow'], null>>,
|
||||
default: null,
|
||||
},
|
||||
|
||||
getReferencedVirtualElement: {
|
||||
type: Function as PropType<
|
||||
Exclude<Required<BubbleMenuPluginProps>['getReferencedVirtualElement'], null>
|
||||
>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props, { slots, attrs }) {
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const resolvedPluginKey = props.pluginKey ?? new PluginKey('bubbleMenu')
|
||||
|
||||
onMounted(() => {
|
||||
const {
|
||||
editor,
|
||||
options,
|
||||
resizeDelay,
|
||||
appendTo,
|
||||
shouldShow,
|
||||
getReferencedVirtualElement,
|
||||
updateDelay,
|
||||
} = props
|
||||
|
||||
const el = root.value
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.position = 'absolute'
|
||||
|
||||
// Remove element from DOM; plugin will re-parent it when shown
|
||||
el.remove()
|
||||
|
||||
nextTick(() => {
|
||||
editor.registerPlugin(
|
||||
BubbleMenuPlugin({
|
||||
editor,
|
||||
element: el,
|
||||
options,
|
||||
pluginKey: resolvedPluginKey,
|
||||
resizeDelay,
|
||||
appendTo,
|
||||
shouldShow,
|
||||
getReferencedVirtualElement,
|
||||
updateDelay,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const { editor } = props
|
||||
|
||||
editor.unregisterPlugin(resolvedPluginKey)
|
||||
})
|
||||
|
||||
// Vue owns this element; attrs are applied reactively by Vue
|
||||
// Plugin re-parents it when showing the menu
|
||||
return () => h('div', { ref: root, ...attrs }, slots.default?.())
|
||||
},
|
||||
})
|
||||
94
packages/vue/src/menus/FloatingMenu.ts
Normal file
94
packages/vue/src/menus/FloatingMenu.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { FloatingMenuPluginProps } from '@tiptap/extension-floating-menu'
|
||||
import { FloatingMenuPlugin } from '@tiptap/extension-floating-menu'
|
||||
import { PluginKey } from '@tiptap/pm/state'
|
||||
import type { PropType } from 'vue'
|
||||
import { defineComponent, h, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
export const FloatingMenu = defineComponent({
|
||||
name: 'FloatingMenu',
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
pluginKey: {
|
||||
// TODO: TypeScript breaks :(
|
||||
// type: [String, Object as PropType<Exclude<FloatingMenuPluginProps['pluginKey'], string>>],
|
||||
type: null,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
editor: {
|
||||
type: Object as PropType<FloatingMenuPluginProps['editor']>,
|
||||
required: true,
|
||||
},
|
||||
|
||||
updateDelay: {
|
||||
type: Number as PropType<FloatingMenuPluginProps['updateDelay']>,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
resizeDelay: {
|
||||
type: Number as PropType<FloatingMenuPluginProps['resizeDelay']>,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
options: {
|
||||
type: Object as PropType<FloatingMenuPluginProps['options']>,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
appendTo: {
|
||||
type: [Object, Function] as PropType<FloatingMenuPluginProps['appendTo']>,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
shouldShow: {
|
||||
type: Function as PropType<Exclude<Required<FloatingMenuPluginProps>['shouldShow'], null>>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props, { slots, attrs }) {
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const resolvedPluginKey = props.pluginKey ?? new PluginKey('floatingMenu')
|
||||
|
||||
onMounted(() => {
|
||||
const { editor, updateDelay, resizeDelay, options, appendTo, shouldShow } = props
|
||||
|
||||
const el = root.value
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
el.style.visibility = 'hidden'
|
||||
el.style.position = 'absolute'
|
||||
|
||||
// Remove element from DOM; plugin will re-parent it when shown
|
||||
el.remove()
|
||||
|
||||
editor.registerPlugin(
|
||||
FloatingMenuPlugin({
|
||||
pluginKey: resolvedPluginKey,
|
||||
editor,
|
||||
element: el,
|
||||
updateDelay,
|
||||
resizeDelay,
|
||||
options,
|
||||
appendTo,
|
||||
shouldShow,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const { editor } = props
|
||||
|
||||
editor.unregisterPlugin(resolvedPluginKey)
|
||||
})
|
||||
|
||||
// Vue owns this element; attrs are applied reactively by Vue
|
||||
// Plugin re-parents it when showing the menu
|
||||
return () => h('div', { ref: root, ...attrs }, slots.default?.())
|
||||
},
|
||||
})
|
||||
2
packages/vue/src/menus/index.ts
Normal file
2
packages/vue/src/menus/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './BubbleMenu.js'
|
||||
export * from './FloatingMenu.js'
|
||||
18
packages/vue/src/useEditor.ts
Normal file
18
packages/vue/src/useEditor.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { EditorOptions } from '@tiptap/core'
|
||||
import { onBeforeUnmount, onMounted, shallowRef } from 'vue'
|
||||
|
||||
import { Editor } from './Editor.js'
|
||||
|
||||
export const useEditor = (options: Partial<EditorOptions> = {}) => {
|
||||
const editor = shallowRef<Editor>()
|
||||
|
||||
onMounted(() => {
|
||||
editor.value = new Editor(options)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy()
|
||||
})
|
||||
|
||||
return editor
|
||||
}
|
||||
59
packages/vue/src/utils/undeclaredWidgetProps.test.ts
Normal file
59
packages/vue/src/utils/undeclaredWidgetProps.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
import { undeclaredWidgetProps } from './undeclaredWidgetProps.js'
|
||||
|
||||
const render = () => h('span')
|
||||
|
||||
describe('undeclaredWidgetProps', () => {
|
||||
it('declares the context props a component does not declare', () => {
|
||||
const component = defineComponent({ props: { label: String }, render })
|
||||
|
||||
expect(undeclaredWidgetProps(component, { label: 'a' })).toEqual({
|
||||
editor: null,
|
||||
getPos: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('skips props the component already declares', () => {
|
||||
const component = defineComponent({
|
||||
props: { editor: Object, getPos: Function, label: String },
|
||||
render,
|
||||
})
|
||||
|
||||
expect(undeclaredWidgetProps(component, { label: 'a' })).toEqual({})
|
||||
})
|
||||
|
||||
it('declares passed props the component is missing', () => {
|
||||
const component = defineComponent({ props: { label: String }, render })
|
||||
|
||||
expect(undeclaredWidgetProps(component, { label: 'a', count: 1 })).toEqual({
|
||||
editor: null,
|
||||
getPos: null,
|
||||
count: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('reads array prop declarations', () => {
|
||||
const component = defineComponent({ props: ['label', 'get-pos'], render })
|
||||
|
||||
expect(undeclaredWidgetProps(component, { label: 'a' })).toEqual({ editor: null })
|
||||
})
|
||||
|
||||
it('reads props from extends and mixins', () => {
|
||||
const component = defineComponent({
|
||||
extends: defineComponent({ props: { editor: Object }, render }),
|
||||
mixins: [defineComponent({ props: { label: String }, render })],
|
||||
render,
|
||||
})
|
||||
|
||||
expect(undeclaredWidgetProps(component, { label: 'a' })).toEqual({ getPos: null })
|
||||
})
|
||||
|
||||
it('handles a component without props', () => {
|
||||
expect(undeclaredWidgetProps(defineComponent({ render }), {})).toEqual({
|
||||
editor: null,
|
||||
getPos: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
57
packages/vue/src/utils/undeclaredWidgetProps.ts
Normal file
57
packages/vue/src/utils/undeclaredWidgetProps.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Component } from 'vue'
|
||||
import { camelize } from 'vue'
|
||||
|
||||
interface OptionsComponent {
|
||||
props?: string[] | Record<string, unknown>
|
||||
extends?: Component
|
||||
mixins?: Component[]
|
||||
}
|
||||
|
||||
function collectPropNames(component: Component | undefined, names: Set<string>): void {
|
||||
const options = component as OptionsComponent | undefined
|
||||
|
||||
if (!options) {
|
||||
return
|
||||
}
|
||||
|
||||
options.mixins?.forEach(mixin => collectPropNames(mixin, names))
|
||||
collectPropNames(options.extends, names)
|
||||
|
||||
const { props } = options
|
||||
|
||||
if (Array.isArray(props)) {
|
||||
props.forEach(name => names.add(camelize(name)))
|
||||
} else if (props) {
|
||||
Object.keys(props).forEach(name => names.add(camelize(name)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds prop declarations for the widget props a component does not declare itself.
|
||||
* Declaring them keeps Vue from rendering them as DOM attributes, while leaving
|
||||
* the component's own `type`, `default` and `validator` options untouched.
|
||||
*
|
||||
* @param component The widget component, including its `extends` and `mixins` chain.
|
||||
* @param props The props passed through `VueWidgetRenderer`.
|
||||
* @returns A Vue props object for the undeclared keys only.
|
||||
* @example
|
||||
* undeclaredWidgetProps(MyWidget, { label: 'a' }) // => { editor: null, getPos: null }
|
||||
*/
|
||||
export function undeclaredWidgetProps(
|
||||
component: Component,
|
||||
props: Record<string, any>,
|
||||
): Record<string, null> {
|
||||
const declared = new Set<string>()
|
||||
|
||||
collectPropNames(component, declared)
|
||||
|
||||
const declarations: Record<string, null> = {}
|
||||
|
||||
for (const name of ['editor', 'getPos', ...Object.keys(props)]) {
|
||||
if (!declared.has(camelize(name))) {
|
||||
declarations[name] = null
|
||||
}
|
||||
}
|
||||
|
||||
return declarations
|
||||
}
|
||||
42
packages/vue/src/utils/wrapFunctionalWidget.ts
Normal file
42
packages/vue/src/utils/wrapFunctionalWidget.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { FunctionalComponent } from 'vue'
|
||||
|
||||
import { undeclaredWidgetProps } from './undeclaredWidgetProps.js'
|
||||
|
||||
type PropsOption = FunctionalComponent['props']
|
||||
|
||||
/** Keeps an array declaration an array, so Vue does not read numeric keys as prop names. */
|
||||
function mergePropsOption(declared: PropsOption, undeclared: Record<string, null>): PropsOption {
|
||||
if (Array.isArray(declared)) {
|
||||
return [...declared, ...Object.keys(undeclared)]
|
||||
}
|
||||
|
||||
return { ...(declared as object), ...undeclared } as PropsOption
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a functional component so it can be used as a widget decoration.
|
||||
*
|
||||
* A functional component is its own render function, so the options wrapper
|
||||
* used for object components would spread it away and leave Vue with nothing
|
||||
* to render.
|
||||
*
|
||||
* @param component The functional component.
|
||||
* @param props The props passed through `VueWidgetRenderer`.
|
||||
* @returns A functional component declaring the widget props.
|
||||
* @example
|
||||
* wrapFunctionalWidget(props => h('span', props.label), { label: 'a' })
|
||||
*/
|
||||
export function wrapFunctionalWidget(
|
||||
component: FunctionalComponent,
|
||||
props: Record<string, any>,
|
||||
): FunctionalComponent {
|
||||
const wrapped: FunctionalComponent = (componentProps, context) =>
|
||||
component(componentProps, context)
|
||||
|
||||
wrapped.props = mergePropsOption(component.props, undeclaredWidgetProps(component, props))
|
||||
wrapped.emits = component.emits
|
||||
wrapped.inheritAttrs = component.inheritAttrs
|
||||
wrapped.displayName = component.displayName ?? component.name
|
||||
|
||||
return wrapped
|
||||
}
|
||||
Reference in New Issue
Block a user