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

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

View 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
}
}

View 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
},
})
},
})

View 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': '',
})
},
})

View 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?.(),
)
},
})

View 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')
})
})

View 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)
}
}

View 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')
})
})

View 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)
}
}

View 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()
})
})

View 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()
}
}

View 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.
})

View 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
View 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'

View 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?.())
},
})

View 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?.())
},
})

View File

@@ -0,0 +1,2 @@
export * from './BubbleMenu.js'
export * from './FloatingMenu.js'

View 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
}

View 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,
})
})
})

View 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
}

View 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
}