mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-13 21:23:16 +08:00
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
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:
parent
569ab6200e
commit
0e3207fc11
7
.changeset/gold-ads-own.md
Normal file
7
.changeset/gold-ads-own.md
Normal 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
|
23
demos/src/GuideMarkViews/ReactComponent/React/Component.tsx
Normal file
23
demos/src/GuideMarkViews/ReactComponent/React/Component.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
24
demos/src/GuideMarkViews/ReactComponent/React/Extension.ts
Normal file
24
demos/src/GuideMarkViews/ReactComponent/React/Extension.ts
Normal 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)
|
||||||
|
},
|
||||||
|
})
|
32
demos/src/GuideMarkViews/ReactComponent/React/index.spec.js
Normal file
32
demos/src/GuideMarkViews/ReactComponent/React/index.spec.js
Normal 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.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
24
demos/src/GuideMarkViews/ReactComponent/React/index.tsx
Normal file
24
demos/src/GuideMarkViews/ReactComponent/React/index.tsx
Normal 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 you’re used to, but enriched with node views.
|
||||||
|
</p>
|
||||||
|
<react-component>Sub-text</react-component>
|
||||||
|
<p>
|
||||||
|
Did you see that? That’s a React component. We are really living in the future.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <EditorContent editor={editor} />
|
||||||
|
}
|
116
demos/src/GuideMarkViews/ReactComponent/React/styles.scss
Normal file
116
demos/src/GuideMarkViews/ReactComponent/React/styles.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
58
demos/src/GuideMarkViews/VueComponent/Vue/Component.vue
Normal file
58
demos/src/GuideMarkViews/VueComponent/Vue/Component.vue
Normal 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>
|
24
demos/src/GuideMarkViews/VueComponent/Vue/Extension.ts
Normal file
24
demos/src/GuideMarkViews/VueComponent/Vue/Extension.ts
Normal 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)
|
||||||
|
},
|
||||||
|
})
|
32
demos/src/GuideMarkViews/VueComponent/Vue/index.spec.js
Normal file
32
demos/src/GuideMarkViews/VueComponent/Vue/index.spec.js
Normal 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.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
135
demos/src/GuideMarkViews/VueComponent/Vue/index.vue
Normal file
135
demos/src/GuideMarkViews/VueComponent/Vue/index.vue
Normal 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 you’re used to, but enriched with node views.
|
||||||
|
</p>
|
||||||
|
<vue-component>Sub-text</vue-component>
|
||||||
|
<p>
|
||||||
|
Did you see that? That’s 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>
|
@ -1,7 +1,7 @@
|
|||||||
import { NodeViewWrapper } from '@tiptap/react'
|
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
export default props => {
|
export default (props: NodeViewProps) => {
|
||||||
const increase = () => {
|
const increase = () => {
|
||||||
props.updateAttributes({
|
props.updateAttributes({
|
||||||
count: props.node.attrs.count + 1,
|
count: props.node.attrs.count + 1,
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export const Context = React.createContext({
|
||||||
|
value: 'this is the default value which should not show up',
|
||||||
|
})
|
@ -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)
|
||||||
|
},
|
||||||
|
})
|
@ -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 you’re used to, but enriched with node views.
|
||||||
|
</p>
|
||||||
|
<react-component count="0"></react-component>
|
||||||
|
<p>
|
||||||
|
Did you see that? That’s a React component. We are really living in the future.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={contextValue}>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</Context.Provider>
|
||||||
|
)
|
||||||
|
}
|
116
demos/src/GuideNodeViews/ReactComponentContext/React/styles.scss
Normal file
116
demos/src/GuideNodeViews/ReactComponentContext/React/styles.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -19,6 +19,8 @@ export default () => {
|
|||||||
<p style="font-weight: 500">Cool, isn’t it!?</p>
|
<p style="font-weight: 500">Cool, isn’t it!?</p>
|
||||||
<p style="font-weight: 999">Up to font weight 999!!!</p>
|
<p style="font-weight: 999">Up to font weight 999!!!</p>
|
||||||
`,
|
`,
|
||||||
|
shouldRerenderOnTransaction: true,
|
||||||
|
immediatelyRender: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
@ -415,6 +415,7 @@ export class Editor extends EventEmitter<EditorEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.view.setProps({
|
this.view.setProps({
|
||||||
|
markViews: this.extensionManager.markViews,
|
||||||
nodeViews: this.extensionManager.nodeViews,
|
nodeViews: this.extensionManager.nodeViews,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { keymap } from '@tiptap/pm/keymap'
|
import { keymap } from '@tiptap/pm/keymap'
|
||||||
import { Schema } from '@tiptap/pm/model'
|
import { Schema } from '@tiptap/pm/model'
|
||||||
import { Plugin } from '@tiptap/pm/state'
|
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 type { Editor } from './Editor.js'
|
||||||
import {
|
import {
|
||||||
@ -17,7 +17,7 @@ import {
|
|||||||
sortExtensions,
|
sortExtensions,
|
||||||
splitExtensions,
|
splitExtensions,
|
||||||
} from './helpers/index.js'
|
} 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 { InputRule, inputRulesPlugin } from './InputRule.js'
|
||||||
import { Mark } from './Mark.js'
|
import { Mark } from './Mark.js'
|
||||||
import { PasteRule, pasteRulesPlugin } from './PasteRule.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
|
* Go through all extensions, create extension storages & setup marks
|
||||||
* & bind editor event listener.
|
* & bind editor event listener.
|
||||||
|
@ -14,6 +14,7 @@ import {
|
|||||||
Extensions,
|
Extensions,
|
||||||
GlobalAttributes,
|
GlobalAttributes,
|
||||||
KeyboardShortcutCommand,
|
KeyboardShortcutCommand,
|
||||||
|
MarkViewRenderer,
|
||||||
ParentConfig,
|
ParentConfig,
|
||||||
RawCommands,
|
RawCommands,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
@ -417,6 +418,20 @@ declare module '@tiptap/core' {
|
|||||||
) => void)
|
) => void)
|
||||||
| null
|
| 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
|
* Keep mark after split node
|
||||||
*/
|
*/
|
||||||
|
66
packages/core/src/MarkView.ts
Normal file
66
packages/core/src/MarkView.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -7,6 +7,7 @@ export * from './InputRule.js'
|
|||||||
export * from './inputRules/index.js'
|
export * from './inputRules/index.js'
|
||||||
export { createElement, Fragment, createElement as h } from './jsx-runtime.js'
|
export { createElement, Fragment, createElement as h } from './jsx-runtime.js'
|
||||||
export * from './Mark.js'
|
export * from './Mark.js'
|
||||||
|
export * from './MarkView.js'
|
||||||
export * from './Node.js'
|
export * from './Node.js'
|
||||||
export * from './NodePos.js'
|
export * from './NodePos.js'
|
||||||
export * from './NodeView.js'
|
export * from './NodeView.js'
|
||||||
|
@ -6,6 +6,8 @@ import {
|
|||||||
DecorationAttrs,
|
DecorationAttrs,
|
||||||
EditorProps,
|
EditorProps,
|
||||||
EditorView,
|
EditorView,
|
||||||
|
MarkView,
|
||||||
|
MarkViewConstructor,
|
||||||
NodeView,
|
NodeView,
|
||||||
NodeViewConstructor,
|
NodeViewConstructor,
|
||||||
ViewMutationRecord,
|
ViewMutationRecord,
|
||||||
@ -594,6 +596,44 @@ export interface NodeViewRendererProps {
|
|||||||
|
|
||||||
export type NodeViewRenderer = (props: NodeViewRendererProps) => NodeView
|
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 AnyCommands = Record<string, (...args: any[]) => Command>
|
||||||
|
|
||||||
export type UnionCommands<T = Command> = UnionToIntersection<
|
export type UnionCommands<T = Command> = UnionToIntersection<
|
||||||
|
@ -5,6 +5,7 @@ export * from './elementFromString.js'
|
|||||||
export * from './escapeForRegEx.js'
|
export * from './escapeForRegEx.js'
|
||||||
export * from './findDuplicates.js'
|
export * from './findDuplicates.js'
|
||||||
export * from './fromString.js'
|
export * from './fromString.js'
|
||||||
|
export * from './isAndroid.js'
|
||||||
export * from './isEmptyObject.js'
|
export * from './isEmptyObject.js'
|
||||||
export * from './isFunction.js'
|
export * from './isFunction.js'
|
||||||
export * from './isiOS.js'
|
export * from './isiOS.js'
|
||||||
|
108
packages/react/src/ReactMarkViewRenderer.tsx
Normal file
108
packages/react/src/ReactMarkViewRenderer.tsx
Normal 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)
|
||||||
|
}
|
@ -4,6 +4,7 @@ export * from './EditorContent.js'
|
|||||||
export * from './FloatingMenu.js'
|
export * from './FloatingMenu.js'
|
||||||
export * from './NodeViewContent.js'
|
export * from './NodeViewContent.js'
|
||||||
export * from './NodeViewWrapper.js'
|
export * from './NodeViewWrapper.js'
|
||||||
|
export * from './ReactMarkViewRenderer.js'
|
||||||
export * from './ReactNodeViewRenderer.js'
|
export * from './ReactNodeViewRenderer.js'
|
||||||
export * from './ReactRenderer.js'
|
export * from './ReactRenderer.js'
|
||||||
export * from './useEditor.js'
|
export * from './useEditor.js'
|
||||||
|
112
packages/vue-3/src/VueMarkViewRenderer.ts
Normal file
112
packages/vue-3/src/VueMarkViewRenderer.ts
Normal 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 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)
|
||||||
|
}
|
||||||
|
}
|
@ -5,6 +5,7 @@ export * from './FloatingMenu.js'
|
|||||||
export * from './NodeViewContent.js'
|
export * from './NodeViewContent.js'
|
||||||
export * from './NodeViewWrapper.js'
|
export * from './NodeViewWrapper.js'
|
||||||
export * from './useEditor.js'
|
export * from './useEditor.js'
|
||||||
|
export * from './VueMarkViewRenderer.js'
|
||||||
export * from './VueNodeViewRenderer.js'
|
export * from './VueNodeViewRenderer.js'
|
||||||
export * from './VueRenderer.js'
|
export * from './VueRenderer.js'
|
||||||
export * from '@tiptap/core'
|
export * from '@tiptap/core'
|
||||||
|
Loading…
Reference in New Issue
Block a user