feat(core): add support for markviews (#5759)
Some checks are pending
build / build (20) (push) Waiting to run
build / test (20, map[name:Demos/Commands spec:./demos/src/Commands/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/Examples spec:./demos/src/Examples/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/Experiments spec:./demos/src/Experiments/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/Extensions spec:./demos/src/Extensions/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/GuideContent spec:./demos/src/GuideContent/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/GuideGettingStarted spec:./demos/src/GuideGettingStarted/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/Marks spec:./demos/src/Marks/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Demos/Nodes spec:./demos/src/Nodes/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / test (20, map[name:Integration spec:./tests/cypress/integration/**/*.spec.{js,ts}]) (push) Blocked by required conditions
build / release (20) (push) Blocked by required conditions
Publish / Release (20) (push) Waiting to run

This commit is contained in:
Nick Perez 2025-01-22 16:00:39 +01:00 committed by GitHub
parent 569ab6200e
commit 0e3207fc11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1083 additions and 4 deletions

View File

@ -0,0 +1,7 @@
---
'@tiptap/react': minor
'@tiptap/vue-3': minor
'@tiptap/core': minor
---
Add support for [markviews](https://prosemirror.net/docs/ref/#view.MarkView), with support for React & Vue-3 MarkViewRenderers

View File

@ -0,0 +1,23 @@
import { MarkViewContent, MarkViewRendererProps } from '@tiptap/react'
import React from 'react'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default (props: MarkViewRendererProps) => {
const [count, setCount] = React.useState(0)
return (
<span className="content" data-test-id="mark-view">
<MarkViewContent />
<label contentEditable={false}>
React component:
<button
onClick={() => {
setCount(count + 1)
}}
>
This button has been clicked {count} times.
</button>
</label>
</span>
)
}

View File

@ -0,0 +1,24 @@
import { Mark } from '@tiptap/core'
import { ReactMarkViewRenderer } from '@tiptap/react'
import Component from './Component.js'
export default Mark.create({
name: 'reactComponent',
parseHTML() {
return [
{
tag: 'react-component',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['react-component', HTMLAttributes]
},
addMarkView() {
return ReactMarkViewRenderer(Component)
},
})

View File

@ -0,0 +1,32 @@
/// <reference types="cypress" />
context('/src/GuideMarkViews/ReactComponent/React/', () => {
before(() => {
cy.visit('/src/GuideMarkViews/ReactComponent/React/')
})
beforeEach(() => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<p>Example Text</p><react-component>Mark View Text</react-component>')
})
cy.get('.tiptap').type('{selectall}')
})
it('should show the markview', () => {
cy.get('.tiptap').find('[data-test-id="mark-view"]').should('exist')
})
it('should allow clicking the button', () => {
cy.get('.tiptap')
.find('[data-test-id="mark-view"] button')
.should('contain', 'This button has been clicked 0 times.')
cy.get('.tiptap')
.find('[data-test-id="mark-view"] button')
.click()
.then(() => {
cy.get('.tiptap')
.find('[data-test-id="mark-view"] button')
.should('contain', 'This button has been clicked 1 times.')
})
})
})

View File

@ -0,0 +1,24 @@
import './styles.scss'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React from 'react'
import ReactComponent from './Extension.js'
export default () => {
const editor = useEditor({
extensions: [StarterKit, ReactComponent],
content: `
<p>
This is still the text editor youre used to, but enriched with node views.
</p>
<react-component>Sub-text</react-component>
<p>
Did you see that? Thats a React component. We are really living in the future.
</p>
`,
})
return <EditorContent editor={editor} />
}

View File

@ -0,0 +1,116 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
/* List styles */
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
margin-top: 2.5rem;
text-wrap: pretty;
}
h1,
h2 {
margin-top: 3.5rem;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
/* Code and preformatted text styles */
code {
background-color: var(--purple-light);
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
padding: 0.25em 0.3em;
}
pre {
background: var(--black);
border-radius: 0.5rem;
color: var(--white);
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
blockquote {
border-left: 3px solid var(--gray-3);
margin: 1.5rem 0;
padding-left: 1rem;
}
hr {
border: none;
border-top: 1px solid var(--gray-2);
margin: 2rem 0;
}
/* React component */
.react-component {
background-color: var(--purple-light);
border: 2px solid var(--purple);
border-radius: 0.5rem;
margin: 2rem 0;
position: relative;
label {
background-color: var(--purple);
border-radius: 0 0 0.5rem 0;
color: var(--white);
font-size: 0.75rem;
font-weight: bold;
padding: 0.25rem 0.5rem;
position: absolute;
top: 0;
}
.content {
margin-top: 1.5rem;
padding: 1rem;
}
}
}

View File

@ -0,0 +1,58 @@
<template>
<span className="content" data-test-id="mark-view">
<mark-view-content />
<label contenteditable="false"
>Vue Component::
<button @click="increase" class="primary">This button has been clicked {{ count }} times.</button>
</label>
</span>
</template>
<script>
import { MarkViewContent, markViewProps } from '@tiptap/vue-3'
export default {
components: {
MarkViewContent,
},
data() {
return {
count: 0,
}
},
props: markViewProps,
methods: {
increase() {
this.count += 1
},
},
}
</script>
<style lang="scss">
.tiptap {
/* Vue component */
.vue-component {
background-color: var(--purple-light);
border: 2px solid var(--purple);
border-radius: 0.5rem;
label {
background-color: var(--purple);
border-radius: 0 0 0.5rem 0;
color: var(--white);
font-size: 0.75rem;
font-weight: bold;
padding: 0.25rem 0.5rem;
}
.content {
margin-top: 1.5rem;
padding: 1rem;
}
}
}
</style>

View File

@ -0,0 +1,24 @@
import { Mark } from '@tiptap/core'
import { VueMarkViewRenderer } from '@tiptap/vue-3'
import Component from './Component.vue'
export default Mark.create({
name: 'vueComponent',
parseHTML() {
return [
{
tag: 'vue-component',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['vue-component', HTMLAttributes]
},
addMarkView() {
return VueMarkViewRenderer(Component)
},
})

View File

@ -0,0 +1,32 @@
/// <reference types="cypress" />
context('/src/GuideMarkViews/VueComponent/Vue/', () => {
before(() => {
cy.visit('/src/GuideMarkViews/VueComponent/Vue/')
})
beforeEach(() => {
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<p>Example Text</p><vue-component>Mark View Text</vue-component>')
})
cy.get('.tiptap').type('{selectall}')
})
it('should show the markview', () => {
cy.get('.tiptap').find('[data-test-id="mark-view"]').should('exist')
})
it('should allow clicking the button', () => {
cy.get('.tiptap')
.find('[data-test-id="mark-view"] button')
.should('contain', 'This button has been clicked 0 times.')
cy.get('.tiptap')
.find('[data-test-id="mark-view"] button')
.click()
.then(() => {
cy.get('.tiptap')
.find('[data-test-id="mark-view"] button')
.should('contain', 'This button has been clicked 1 times.')
})
})
})

View File

@ -0,0 +1,135 @@
<template>
<editor-content :editor="editor" />
</template>
<script>
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue-3'
import VueComponent from './Extension.ts'
export default {
components: {
EditorContent,
},
data() {
return {
editor: null,
}
},
mounted() {
this.editor = new Editor({
extensions: [StarterKit, VueComponent],
content: `
<p>
This is still the text editor youre used to, but enriched with node views.
</p>
<vue-component>Sub-text</vue-component>
<p>
Did you see that? Thats a Vue component. We are really living in the future.
</p>
`,
})
},
beforeUnmount() {
this.editor.destroy()
},
}
</script>
<style lang="scss">
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
/* List styles */
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
margin-top: 2.5rem;
text-wrap: pretty;
}
h1,
h2 {
margin-top: 3.5rem;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
/* Code and preformatted text styles */
code {
background-color: var(--purple-light);
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
padding: 0.25em 0.3em;
}
pre {
background: var(--black);
border-radius: 0.5rem;
color: var(--white);
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
blockquote {
border-left: 3px solid var(--gray-3);
margin: 1.5rem 0;
padding-left: 1rem;
}
hr {
border: none;
border-top: 1px solid var(--gray-2);
margin: 2rem 0;
}
}
</style>

View File

@ -1,7 +1,7 @@
import { NodeViewWrapper } from '@tiptap/react'
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react'
import React from 'react'
export default props => {
export default (props: NodeViewProps) => {
const increase = () => {
props.updateAttributes({
count: props.node.attrs.count + 1,

View File

@ -0,0 +1,24 @@
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react'
import React, { useContext } from 'react'
import { Context } from './Context.js'
export default (props: NodeViewProps) => {
const { value } = useContext(Context)
const increase = () => {
props.updateAttributes({
count: props.node.attrs.count + 1,
})
}
return (
<NodeViewWrapper className="react-component">
<label>React Component: {value}</label>
<div className="content">
<button onClick={increase}>This button has been clicked {props.node.attrs.count} times.</button>
</div>
</NodeViewWrapper>
)
}

View File

@ -0,0 +1,5 @@
import React from 'react'
export const Context = React.createContext({
value: 'this is the default value which should not show up',
})

View File

@ -0,0 +1,36 @@
import { mergeAttributes, Node } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import Component from './Component.js'
export default Node.create({
name: 'reactComponent',
group: 'block',
atom: true,
addAttributes() {
return {
count: {
default: 0,
},
}
},
parseHTML() {
return [
{
tag: 'react-component',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['react-component', mergeAttributes(HTMLAttributes)]
},
addNodeView() {
return ReactNodeViewRenderer(Component)
},
})

View File

@ -0,0 +1,33 @@
import './styles.scss'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React from 'react'
import { Context } from './Context.js'
import ReactComponent from './Extension.js'
const contextValue = {
value: 'Hi from react context!',
}
export default () => {
const editor = useEditor({
extensions: [StarterKit, ReactComponent],
content: `
<p>
This is still the text editor youre used to, but enriched with node views.
</p>
<react-component count="0"></react-component>
<p>
Did you see that? Thats a React component. We are really living in the future.
</p>
`,
})
return (
<Context.Provider value={contextValue}>
<EditorContent editor={editor} />
</Context.Provider>
)
}

View File

@ -0,0 +1,116 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
/* List styles */
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
margin-top: 2.5rem;
text-wrap: pretty;
}
h1,
h2 {
margin-top: 3.5rem;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
/* Code and preformatted text styles */
code {
background-color: var(--purple-light);
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
padding: 0.25em 0.3em;
}
pre {
background: var(--black);
border-radius: 0.5rem;
color: var(--white);
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
blockquote {
border-left: 3px solid var(--gray-3);
margin: 1.5rem 0;
padding-left: 1rem;
}
hr {
border: none;
border-top: 1px solid var(--gray-2);
margin: 2rem 0;
}
/* React component */
.react-component {
background-color: var(--purple-light);
border: 2px solid var(--purple);
border-radius: 0.5rem;
margin: 2rem 0;
position: relative;
label {
background-color: var(--purple);
border-radius: 0 0 0.5rem 0;
color: var(--white);
font-size: 0.75rem;
font-weight: bold;
padding: 0.25rem 0.5rem;
position: absolute;
top: 0;
}
.content {
margin-top: 1.5rem;
padding: 1rem;
}
}
}

View File

@ -19,6 +19,8 @@ export default () => {
<p style="font-weight: 500">Cool, isnt it!?</p>
<p style="font-weight: 999">Up to font weight 999!!!</p>
`,
shouldRerenderOnTransaction: true,
immediatelyRender: true,
})
if (!editor) {

View File

@ -415,6 +415,7 @@ export class Editor extends EventEmitter<EditorEvents> {
}
this.view.setProps({
markViews: this.extensionManager.markViews,
nodeViews: this.extensionManager.nodeViews,
})
}

View File

@ -1,7 +1,7 @@
import { keymap } from '@tiptap/pm/keymap'
import { Schema } from '@tiptap/pm/model'
import { Plugin } from '@tiptap/pm/state'
import { NodeViewConstructor } from '@tiptap/pm/view'
import { MarkViewConstructor, NodeViewConstructor } from '@tiptap/pm/view'
import type { Editor } from './Editor.js'
import {
@ -17,7 +17,7 @@ import {
sortExtensions,
splitExtensions,
} from './helpers/index.js'
import type { NodeConfig } from './index.js'
import { type MarkConfig, type NodeConfig, getMarkType } from './index.js'
import { InputRule, inputRulesPlugin } from './InputRule.js'
import { Mark } from './Mark.js'
import { PasteRule, pasteRulesPlugin } from './PasteRule.js'
@ -226,6 +226,48 @@ export class ExtensionManager {
)
}
get markViews(): Record<string, MarkViewConstructor> {
const { editor } = this
const { markExtensions } = splitExtensions(this.extensions)
return Object.fromEntries(
markExtensions
.filter(extension => !!getExtensionField(extension, 'addMarkView'))
.map(extension => {
const extensionAttributes = this.attributes.filter(attribute => attribute.type === extension.name)
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage,
editor,
type: getMarkType(extension.name, this.schema),
}
const addMarkView = getExtensionField<MarkConfig['addMarkView']>(extension, 'addMarkView', context)
if (!addMarkView) {
return []
}
const markView: MarkViewConstructor = (mark, view, inline) => {
const HTMLAttributes = getRenderedAttributes(mark, extensionAttributes)
return addMarkView()({
// pass-through
mark,
view,
inline,
// tiptap-specific
editor,
extension,
HTMLAttributes,
})
}
return [extension.name, markView]
}),
)
}
/**
* Go through all extensions, create extension storages & setup marks
* & bind editor event listener.

View File

@ -14,6 +14,7 @@ import {
Extensions,
GlobalAttributes,
KeyboardShortcutCommand,
MarkViewRenderer,
ParentConfig,
RawCommands,
} from './types.js'
@ -417,6 +418,20 @@ declare module '@tiptap/core' {
) => void)
| null
/**
* Node View
*/
addMarkView?:
| ((this: {
name: string
options: Options
storage: Storage
editor: Editor
type: MarkType
parent: ParentConfig<MarkConfig<Options, Storage>>['addMarkView']
}) => MarkViewRenderer)
| null
/**
* Keep mark after split node
*/

View File

@ -0,0 +1,66 @@
import { ViewMutationRecord } from '@tiptap/pm/view'
import { Editor } from './Editor.js'
import { MarkViewProps, MarkViewRendererOptions } from './types.js'
import { isAndroid, isiOS } from './utilities/index.js'
export class MarkView<Component, Options extends MarkViewRendererOptions = MarkViewRendererOptions> {
component: Component
editor: Editor
options: Options
mark: MarkViewProps['mark']
HTMLAttributes: MarkViewProps['HTMLAttributes']
constructor(component: Component, props: MarkViewProps, options?: Partial<Options>) {
this.component = component
this.editor = props.editor
this.options = { ...options } as Options
this.mark = props.mark
this.HTMLAttributes = props.HTMLAttributes
}
get dom(): HTMLElement {
return this.editor.view.dom
}
get contentDOM(): HTMLElement | null {
return null
}
ignoreMutation(mutation: ViewMutationRecord): boolean {
if (!this.dom || !this.contentDOM) {
return true
}
if (typeof this.options.ignoreMutation === 'function') {
return this.options.ignoreMutation({ mutation })
}
if (mutation.type === 'selection') {
return false
}
if (
this.dom.contains(mutation.target) &&
mutation.type === 'childList' &&
(isiOS() || isAndroid()) &&
this.editor.isFocused
) {
const changedNodes = [...Array.from(mutation.addedNodes), ...Array.from(mutation.removedNodes)] as HTMLElement[]
if (changedNodes.every(node => node.isContentEditable)) {
return false
}
}
if (this.contentDOM === mutation.target && mutation.type === 'attributes') {
return true
}
if (this.contentDOM.contains(mutation.target)) {
return false
}
return true
}
}

View File

@ -7,6 +7,7 @@ export * from './InputRule.js'
export * from './inputRules/index.js'
export { createElement, Fragment, createElement as h } from './jsx-runtime.js'
export * from './Mark.js'
export * from './MarkView.js'
export * from './Node.js'
export * from './NodePos.js'
export * from './NodeView.js'

View File

@ -6,6 +6,8 @@ import {
DecorationAttrs,
EditorProps,
EditorView,
MarkView,
MarkViewConstructor,
NodeView,
NodeViewConstructor,
ViewMutationRecord,
@ -594,6 +596,44 @@ export interface NodeViewRendererProps {
export type NodeViewRenderer = (props: NodeViewRendererProps) => NodeView
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface MarkViewProps extends MarkViewRendererProps {}
export interface MarkViewRendererProps {
// pass-through from prosemirror
/**
* The node that is being rendered.
*/
mark: Parameters<MarkViewConstructor>[0]
/**
* The editor's view.
*/
view: Parameters<MarkViewConstructor>[1]
/**
* indicates whether the mark's content is inline
*/
inline: Parameters<MarkViewConstructor>[2]
// tiptap-specific
/**
* The editor instance.
*/
editor: Editor
/**
* The extension that is responsible for the mark.
*/
extension: Mark
/**
* The HTML attributes that should be added to the mark's DOM element.
*/
HTMLAttributes: Record<string, any>
}
export type MarkViewRenderer = (props: MarkViewRendererProps) => MarkView
export interface MarkViewRendererOptions {
ignoreMutation: ((props: { mutation: ViewMutationRecord }) => boolean) | null
}
export type AnyCommands = Record<string, (...args: any[]) => Command>
export type UnionCommands<T = Command> = UnionToIntersection<

View File

@ -5,6 +5,7 @@ export * from './elementFromString.js'
export * from './escapeForRegEx.js'
export * from './findDuplicates.js'
export * from './fromString.js'
export * from './isAndroid.js'
export * from './isEmptyObject.js'
export * from './isFunction.js'
export * from './isiOS.js'

View File

@ -0,0 +1,108 @@
/* eslint-disable @typescript-eslint/no-shadow */
import { MarkView, MarkViewProps, MarkViewRenderer, MarkViewRendererOptions } from '@tiptap/core'
import React from 'react'
// import { flushSync } from 'react-dom'
import { ReactRenderer } from './ReactRenderer.js'
export interface MarkViewContextProps {
markViewContentRef: (element: HTMLElement | null) => void
}
export const ReactMarkViewContext = React.createContext<MarkViewContextProps>({
markViewContentRef: () => {
// do nothing
},
})
export type MarkViewContentProps<T extends keyof React.JSX.IntrinsicElements = 'span'> = {
as?: NoInfer<T>
} & React.ComponentProps<T>
export const MarkViewContent: React.FC<MarkViewContentProps> = props => {
const Tag = props.as || 'span'
const { markViewContentRef } = React.useContext(ReactMarkViewContext)
return (
// @ts-ignore
<Tag {...props} ref={markViewContentRef} data-mark-view-content="" />
)
}
export interface ReactMarkViewRendererOptions extends MarkViewRendererOptions {
/**
* The tag name of the element wrapping the React component.
*/
as?: string
className?: string
attrs?: { [key: string]: string }
}
export class ReactMarkView extends MarkView<React.ComponentType<MarkViewProps>, ReactMarkViewRendererOptions> {
renderer: ReactRenderer
contentDOMElement: HTMLElement | null
didMountContentDomElement = false
constructor(
component: React.ComponentType<MarkViewProps>,
props: MarkViewProps,
options?: Partial<ReactMarkViewRendererOptions>,
) {
super(component, props, options)
const { as = 'span', attrs, className = '' } = options || {}
const componentProps = props satisfies MarkViewProps
this.contentDOMElement = document.createElement('span')
const markViewContentRef: MarkViewContextProps['markViewContentRef'] = el => {
if (el && this.contentDOMElement && el.firstChild !== this.contentDOMElement) {
el.appendChild(this.contentDOMElement)
this.didMountContentDomElement = true
}
}
const context: MarkViewContextProps = {
markViewContentRef,
}
// For performance reasons, we memoize the provider component
// And all of the things it requires are declared outside of the component, so it doesn't need to re-render
const ReactMarkViewProvider: React.FunctionComponent<MarkViewProps> = React.memo(componentProps => {
return (
<ReactMarkViewContext.Provider value={context}>
{React.createElement(component, componentProps)}
</ReactMarkViewContext.Provider>
)
})
ReactMarkViewProvider.displayName = 'ReactNodeView'
this.renderer = new ReactRenderer(ReactMarkViewProvider, {
editor: props.editor,
props: componentProps,
as,
className: `mark-${props.mark.type.name} ${className}`.trim(),
})
if (attrs) {
this.renderer.updateAttributes(attrs)
}
}
get dom() {
return this.renderer.element as HTMLElement
}
get contentDOM() {
if (!this.didMountContentDomElement) {
return null
}
return this.contentDOMElement as HTMLElement
}
}
export function ReactMarkViewRenderer(
component: React.ComponentType<MarkViewProps>,
options: Partial<ReactMarkViewRendererOptions> = {},
): MarkViewRenderer {
return props => new ReactMarkView(component, props, options)
}

View File

@ -4,6 +4,7 @@ export * from './EditorContent.js'
export * from './FloatingMenu.js'
export * from './NodeViewContent.js'
export * from './NodeViewWrapper.js'
export * from './ReactMarkViewRenderer.js'
export * from './ReactNodeViewRenderer.js'
export * from './ReactRenderer.js'
export * from './useEditor.js'

View File

@ -0,0 +1,112 @@
/* eslint-disable no-underscore-dangle */
import { MarkView, MarkViewProps, MarkViewRenderer, MarkViewRendererOptions } from '@tiptap/core'
import { Component, defineComponent, h, PropType } from 'vue'
import { 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,
},
}
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)
// Create extended component with provide
const extendedComponent = defineComponent({
extends: { ...component },
props: Object.keys(props),
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,
})
}
get dom() {
return this.renderer.element as HTMLElement
}
get contentDOM() {
return this.dom.querySelector('[data-mark-view-content]') as HTMLElement | null
}
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 its `undefined` because <editor-content> isnt rendered yet
if (!(props.editor as Editor).contentComponent) {
return {} as unknown as MarkView<any, any>
}
return new VueMarkView(component, props, options)
}
}

View File

@ -5,6 +5,7 @@ export * from './FloatingMenu.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 '@tiptap/core'