mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-12 21:00:02 +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'
|
||||
|
||||
export default props => {
|
||||
export default (props: NodeViewProps) => {
|
||||
const increase = () => {
|
||||
props.updateAttributes({
|
||||
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: 999">Up to font weight 999!!!</p>
|
||||
`,
|
||||
shouldRerenderOnTransaction: true,
|
||||
immediatelyRender: true,
|
||||
})
|
||||
|
||||
if (!editor) {
|
@ -415,6 +415,7 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
}
|
||||
|
||||
this.view.setProps({
|
||||
markViews: this.extensionManager.markViews,
|
||||
nodeViews: this.extensionManager.nodeViews,
|
||||
})
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
*/
|
||||
|
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 { 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'
|
||||
|
@ -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<
|
||||
|
@ -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'
|
||||
|
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 './NodeViewContent.js'
|
||||
export * from './NodeViewWrapper.js'
|
||||
export * from './ReactMarkViewRenderer.js'
|
||||
export * from './ReactNodeViewRenderer.js'
|
||||
export * from './ReactRenderer.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 './NodeViewWrapper.js'
|
||||
export * from './useEditor.js'
|
||||
export * from './VueMarkViewRenderer.js'
|
||||
export * from './VueNodeViewRenderer.js'
|
||||
export * from './VueRenderer.js'
|
||||
export * from '@tiptap/core'
|
||||
|
Loading…
Reference in New Issue
Block a user