mirror of
https://github.com/ueberdosis/tiptap.git
synced 2024-11-23 19:19:03 +08:00
Merge branch 'develop' into next
This commit is contained in:
commit
ad7ea1a072
5
.changeset/chatty-pianos-learn.md
Normal file
5
.changeset/chatty-pianos-learn.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/core": patch
|
||||
---
|
||||
|
||||
preserve existing node attributes when running setNode
|
5
.changeset/fresh-coats-relate.md
Normal file
5
.changeset/fresh-coats-relate.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/core": minor
|
||||
---
|
||||
|
||||
Previously, only a json representation of the node could be inserted into the editor. This change allows for the insertion of Prosemirror `Node`s and `Fragment`s directly into the editor through the `insertContentAt`, `setContent` and `insertContent` commands.
|
5
.changeset/funny-otters-protect.md
Normal file
5
.changeset/funny-otters-protect.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/core": patch
|
||||
---
|
||||
|
||||
Addresses a bug with `insertContentAt`'s `simulatedPasteRules` option where it could only accept text and not Prosemirror `Node` and `Content`
|
5
.changeset/happy-vans-smash.md
Normal file
5
.changeset/happy-vans-smash.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/core": patch
|
||||
---
|
||||
|
||||
Updates the types of `addOptions` and `addStorage` to have the parent be possibly undefined which is the most accurate typing
|
5
.changeset/serious-coins-fail.md
Normal file
5
.changeset/serious-coins-fail.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/extension-mention": patch
|
||||
---
|
||||
|
||||
add zero-width space to resolve cursor selection issue
|
5
.changeset/two-rats-watch.md
Normal file
5
.changeset/two-rats-watch.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@tiptap/extension-table": patch
|
||||
---
|
||||
|
||||
enforce cellMinWidth even on column not resized by the user, fixes #5435
|
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Load cached dependencies
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.2
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
@ -114,7 +114,7 @@ jobs:
|
||||
quiet: true
|
||||
|
||||
- name: Export screenshots (on failure only)
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
uses: actions/upload-artifact@v4.4.3
|
||||
if: failure()
|
||||
with:
|
||||
name: cypress-screenshots
|
||||
@ -122,7 +122,7 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
- name: Export screen recordings (on failure only)
|
||||
uses: actions/upload-artifact@v4.4.0
|
||||
uses: actions/upload-artifact@v4.4.3
|
||||
if: failure()
|
||||
with:
|
||||
name: cypress-videos
|
||||
@ -147,7 +147,7 @@ jobs:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Load cached dependencies
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.2
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
|
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@ -38,7 +38,7 @@ jobs:
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- name: Load cached dependencies
|
||||
uses: actions/cache@v4.0.2
|
||||
uses: actions/cache@v4.1.2
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
|
@ -2,6 +2,7 @@ import './styles.scss'
|
||||
|
||||
import { Color } from '@tiptap/extension-color'
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import ListItem from '@tiptap/extension-list-item'
|
||||
import TextStyle from '@tiptap/extension-text-style'
|
||||
import { EditorProvider, useCurrentEditor } from '@tiptap/react'
|
||||
@ -57,6 +58,7 @@ const extensions = [
|
||||
Image,
|
||||
Color.configure({ types: [TextStyle.name, ListItem.name] }),
|
||||
TextStyle.configure({ types: [ListItem.name] }),
|
||||
Link,
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
keepMarks: true,
|
||||
|
@ -108,4 +108,28 @@ context('/src/Commands/InsertContent/React/', () => {
|
||||
cy.get('.tiptap').should('contain.html', '<img src="https://example.image/1" alt="This is an example" contenteditable="false" draggable="true"><p>HelloWorld</p>')
|
||||
})
|
||||
})
|
||||
it('should respect editor.options.parseOptions if defined to be `false`', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.options.parseOptions = { preserveWhitespace: false }
|
||||
editor.commands.insertContent('\n<h1>Tiptap</h1><p><strong>Hello\n World</strong>\n</p>\n')
|
||||
cy.get('.tiptap').should('contain.html', '<h1>Tiptap</h1><p><strong>Hello World</strong></p>')
|
||||
})
|
||||
})
|
||||
|
||||
it('should respect editor.options.parseOptions if defined to be `full`', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.options.parseOptions = { preserveWhitespace: 'full' }
|
||||
editor.commands.insertContent('\n<h1>Tiptap</h1><p><strong>Hello\n World</strong>\n</p>\n')
|
||||
cy.get('.tiptap').should('contain.html', '<h1>Tiptap</h1><p><strong>Hello\n World</strong></p>')
|
||||
})
|
||||
})
|
||||
|
||||
it('should respect editor.options.parseOptions if defined to be `true`', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.options.parseOptions = { preserveWhitespace: true }
|
||||
editor.commands.insertContent('<h1>Tiptap</h1><p><strong>Hello\n World</strong>\n</p>')
|
||||
cy.get('.tiptap').should('contain.html', '<h1>Tiptap</h1><p><strong>Hello World</strong></p>')
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
@ -14,6 +14,27 @@ context('/src/Commands/SetContent/React/', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should insert raw JSON content', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.setContent({ type: 'paragraph', content: [{ type: 'text', text: 'Hello World.' }] })
|
||||
cy.get('.tiptap').should('contain.html', '<p>Hello World.</p>')
|
||||
})
|
||||
})
|
||||
|
||||
it('should insert a Prosemirror Node as content', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.setContent(editor.schema.node('paragraph', null, editor.schema.text('Hello World.')))
|
||||
cy.get('.tiptap').should('contain.html', '<p>Hello World.</p>')
|
||||
})
|
||||
})
|
||||
|
||||
it('should insert a Prosemirror Fragment as content', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.setContent(editor.schema.node('doc', null, editor.schema.node('paragraph', null, editor.schema.text('Hello World.'))).content)
|
||||
cy.get('.tiptap').should('contain.html', '<p>Hello World.</p>')
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit updates', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
let updateCount = 0
|
||||
|
@ -72,19 +72,23 @@ const getRandomColor = () => getRandomElement(colors)
|
||||
const getRandomName = () => getRandomElement(names)
|
||||
|
||||
const getInitialUser = () => {
|
||||
return (
|
||||
{
|
||||
name: getRandomName(),
|
||||
color: getRandomColor(),
|
||||
}
|
||||
)
|
||||
return {
|
||||
name: getRandomName(),
|
||||
color: getRandomColor(),
|
||||
}
|
||||
}
|
||||
|
||||
const Editor = ({ ydoc, provider, room }) => {
|
||||
const Editor = ({
|
||||
ydoc, provider, room,
|
||||
}) => {
|
||||
const [status, setStatus] = useState('connecting')
|
||||
const [currentUser, setCurrentUser] = useState(getInitialUser)
|
||||
|
||||
const editor = useEditor({
|
||||
enableContentCheck: true,
|
||||
onContentError: ({ disableCollaboration }) => {
|
||||
disableCollaboration()
|
||||
},
|
||||
onCreate: ({ editor: currentEditor }) => {
|
||||
provider.on('synced', () => {
|
||||
if (currentEditor.isEmpty) {
|
||||
@ -99,13 +103,13 @@ const Editor = ({ ydoc, provider, room }) => {
|
||||
Highlight,
|
||||
TaskList,
|
||||
TaskItem,
|
||||
CharacterCount.configure({
|
||||
CharacterCount.extend().configure({
|
||||
limit: 10000,
|
||||
}),
|
||||
Collaboration.configure({
|
||||
Collaboration.extend().configure({
|
||||
document: ydoc,
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
CollaborationCursor.extend().configure({
|
||||
provider,
|
||||
}),
|
||||
],
|
||||
@ -183,7 +187,10 @@ const Editor = ({ ydoc, provider, room }) => {
|
||||
|
||||
<EditorContent editor={editor} className="main-group" />
|
||||
|
||||
<div className="collab-status-group" data-state={status === 'connected' ? 'online' : 'offline'}>
|
||||
<div
|
||||
className="collab-status-group"
|
||||
data-state={status === 'connected' ? 'online' : 'offline'}
|
||||
>
|
||||
<label>
|
||||
{status === 'connected'
|
||||
? `${editor.storage.collaborationCursor.users.length} user${
|
||||
@ -191,7 +198,9 @@ const Editor = ({ ydoc, provider, room }) => {
|
||||
} online in ${room}`
|
||||
: 'offline'}
|
||||
</label>
|
||||
<button style={{ '--color': currentUser.color }} onClick={setName}>✎ {currentUser.name}</button>
|
||||
<button style={{ '--color': currentUser.color }} onClick={setName}>
|
||||
✎ {currentUser.name}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -9,7 +9,7 @@ const appId = '7j9y6m10'
|
||||
const room = `room.${new Date()
|
||||
.getFullYear()
|
||||
.toString()
|
||||
.slice(-2)}${new Date().getMonth() + 1}${new Date().getDate()}`
|
||||
.slice(-2)}${new Date().getMonth() + 1}${new Date().getDate()}-ok`
|
||||
|
||||
// ydoc and provider for Editor A
|
||||
const ydocA = new Y.Doc()
|
||||
|
@ -157,8 +157,8 @@ export default () => {
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th colspan="3">Description</th>
|
||||
<th colwidth="200">Name</th>
|
||||
<th colspan="3" colwidth="150,100">Description</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cyndi Lauper</td>
|
||||
|
21
demos/src/Examples/Transition/Vue/Component.vue
Normal file
21
demos/src/Examples/Transition/Vue/Component.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<NodeViewWrapper>
|
||||
<label>Vue Component</label>
|
||||
|
||||
<div class="content">
|
||||
<button @click="increase">This button has been clicked {{ node.attrs.count }} times.</button>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nodeViewProps, NodeViewWrapper } from '@tiptap/vue-3'
|
||||
|
||||
const props = defineProps(nodeViewProps)
|
||||
|
||||
function increase() {
|
||||
props.updateAttributes({
|
||||
count: props.node.attrs.count + 1,
|
||||
})
|
||||
}
|
||||
</script>
|
36
demos/src/Examples/Transition/Vue/Extension.js
Normal file
36
demos/src/Examples/Transition/Vue/Extension.js
Normal file
@ -0,0 +1,36 @@
|
||||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
import { VueNodeViewRenderer } from '@tiptap/vue-3'
|
||||
|
||||
import Component from './Component.vue'
|
||||
|
||||
export default Node.create({
|
||||
name: 'vueComponent',
|
||||
|
||||
group: 'block',
|
||||
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
count: {
|
||||
default: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'vue-component',
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['vue-component', mergeAttributes(HTMLAttributes)]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return VueNodeViewRenderer(Component)
|
||||
},
|
||||
})
|
0
demos/src/Examples/Transition/Vue/index.html
Normal file
0
demos/src/Examples/Transition/Vue/index.html
Normal file
29
demos/src/Examples/Transition/Vue/index.spec.js
Normal file
29
demos/src/Examples/Transition/Vue/index.spec.js
Normal file
@ -0,0 +1,29 @@
|
||||
context('/src/Examples/Transition/Vue/', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/src/Examples/Transition/Vue/')
|
||||
})
|
||||
|
||||
it('should not have an active tiptap instance but a button', () => {
|
||||
cy.get('.tiptap').should('not.exist')
|
||||
|
||||
cy.get('#toggle-editor').should('exist')
|
||||
})
|
||||
|
||||
it('clicking the button should show the editor', () => {
|
||||
cy.get('#toggle-editor').click()
|
||||
|
||||
cy.get('.tiptap').should('exist')
|
||||
cy.get('.tiptap').should('be.visible')
|
||||
})
|
||||
|
||||
it('clicking the button again should hide the editor', () => {
|
||||
cy.get('#toggle-editor').click()
|
||||
|
||||
cy.get('.tiptap').should('exist')
|
||||
cy.get('.tiptap').should('be.visible')
|
||||
|
||||
cy.get('#toggle-editor').click()
|
||||
|
||||
cy.get('.tiptap').should('not.exist')
|
||||
})
|
||||
})
|
71
demos/src/Examples/Transition/Vue/index.vue
Normal file
71
demos/src/Examples/Transition/Vue/index.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import VueComponent from './Extension.js'
|
||||
import type { TNote } from './types.js'
|
||||
|
||||
const note = ref<TNote>({
|
||||
id: 'note-1',
|
||||
content: `
|
||||
<p>Some random note text</p>
|
||||
<vue-component count="0"></vue-component>
|
||||
`,
|
||||
})
|
||||
|
||||
const editor = useEditor({
|
||||
content: note.value.content,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'textarea',
|
||||
},
|
||||
},
|
||||
extensions: [
|
||||
StarterKit,
|
||||
VueComponent,
|
||||
],
|
||||
})
|
||||
|
||||
const showEditor = ref(false)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
@click="showEditor = !showEditor"
|
||||
style="margin-bottom: 1rem;"
|
||||
id="toggle-editor"
|
||||
>
|
||||
{{ showEditor ? 'Hide editor' : 'Show editor' }}
|
||||
</button>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="showEditor" class="tiptap-wrapper">
|
||||
<editor-content :editor="editor" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 1s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tiptap-wrapper {
|
||||
background-color: var(--purple-light);
|
||||
border: 2px solid var(--purple);
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
</style>
|
11
demos/src/GuideNodeViews/DragHandle/React/Component.jsx
Normal file
11
demos/src/GuideNodeViews/DragHandle/React/Component.jsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
|
||||
import React from 'react'
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<NodeViewWrapper className="draggable-item">
|
||||
<div className="drag-handle" contentEditable={false} draggable="true" data-drag-handle />
|
||||
<NodeViewContent className="content" />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
30
demos/src/GuideNodeViews/DragHandle/React/DraggableItem.js
Normal file
30
demos/src/GuideNodeViews/DragHandle/React/DraggableItem.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
|
||||
import Component from './Component.jsx'
|
||||
|
||||
export default Node.create({
|
||||
name: 'draggableItem',
|
||||
|
||||
group: 'block',
|
||||
|
||||
content: 'block+',
|
||||
|
||||
draggable: true,
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'div[data-type="draggable-item"]',
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'draggable-item' }), 0]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(Component)
|
||||
},
|
||||
})
|
33
demos/src/GuideNodeViews/DragHandle/React/index.jsx
Normal file
33
demos/src/GuideNodeViews/DragHandle/React/index.jsx
Normal file
@ -0,0 +1,33 @@
|
||||
import './styles.scss'
|
||||
|
||||
import { EditorProvider } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import React from 'react'
|
||||
|
||||
import DraggableItem from './DraggableItem.js'
|
||||
|
||||
const extensions = [
|
||||
StarterKit,
|
||||
DraggableItem,
|
||||
]
|
||||
|
||||
const content = `
|
||||
<p>This is a boring paragraph.</p>
|
||||
<div data-type="draggable-item">
|
||||
<p>Followed by a fancy draggable item.</p>
|
||||
</div>
|
||||
<div data-type="draggable-item">
|
||||
<p>And another draggable item.</p>
|
||||
<div data-type="draggable-item">
|
||||
<p>And a nested one.</p>
|
||||
<div data-type="draggable-item">
|
||||
<p>But can we go deeper?</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>Let’s finish with a boring paragraph.</p>
|
||||
`
|
||||
|
||||
export default () => {
|
||||
return <EditorProvider extensions={extensions} content={content}></EditorProvider>
|
||||
}
|
124
demos/src/GuideNodeViews/DragHandle/React/styles.scss
Normal file
124
demos/src/GuideNodeViews/DragHandle/React/styles.scss
Normal file
@ -0,0 +1,124 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
// Focus styles
|
||||
.has-focus {
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 0 2px var(--purple);
|
||||
}
|
||||
}
|
||||
|
||||
.draggable-item {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
background: white;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.drag-handle {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
top: 0.3rem;
|
||||
margin-right: 0.5rem;
|
||||
cursor: grab;
|
||||
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 16"><path fill-opacity="0.2" d="M4 14c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM2 6C.9 6 0 6.9 0 8s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6C.9 0 0 .9 0 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" /></svg>');
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
@ -29,7 +29,6 @@
|
||||
.hljs-template-variable,
|
||||
.hljs-attribute,
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-regexp,
|
||||
.hljs-link,
|
||||
.hljs-name,
|
||||
|
@ -10,5 +10,8 @@
|
||||
box-decoration-break: clone;
|
||||
color: var(--purple);
|
||||
padding: 0.1rem 0.3rem;
|
||||
&::after {
|
||||
content: "\200B";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -56,6 +56,14 @@ context('/src/Nodes/Table/React/', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sets the minimum width on the colgroups by default (3x1)', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.insertTable({ cols: 3, rows: 1, withHeaderRow: false })
|
||||
|
||||
cy.get('.tiptap').find('col').invoke('attr', 'style').should('eq', 'min-width: 25px;')
|
||||
})
|
||||
})
|
||||
|
||||
it('generates correct markup for a table (1x1)', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: false })
|
||||
@ -63,7 +71,7 @@ context('/src/Nodes/Table/React/', () => {
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).to.equal(
|
||||
'<table style="min-width: 25px"><colgroup><col></colgroup><tbody><tr><td colspan="1" rowspan="1"><p></p></td></tr></tbody></table>',
|
||||
'<table style="min-width: 25px"><colgroup><col style="min-width: 25px"></colgroup><tbody><tr><td colspan="1" rowspan="1"><p></p></td></tr></tbody></table>',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -75,7 +83,7 @@ context('/src/Nodes/Table/React/', () => {
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).to.equal(
|
||||
'<table style="min-width: 25px"><colgroup><col></colgroup><tbody><tr><th colspan="1" rowspan="1"><p></p></th></tr></tbody></table>',
|
||||
'<table style="min-width: 25px"><colgroup><col style="min-width: 25px"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p></p></th></tr></tbody></table>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@ -56,13 +56,23 @@ context('/src/Nodes/Table/Vue/', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sets the minimum width on the colgroups by default (3x1)', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.insertTable({ cols: 3, rows: 1, withHeaderRow: false })
|
||||
|
||||
cy.get('.tiptap').find('col').invoke('attr', 'style').should('eq', 'min-width: 25px;')
|
||||
})
|
||||
})
|
||||
|
||||
it('generates correct markup for a table (1x1)', () => {
|
||||
cy.get('.tiptap').then(([{ editor }]) => {
|
||||
editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: false })
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).to.equal('<table style="min-width: 25px"><colgroup><col></colgroup><tbody><tr><td colspan="1" rowspan="1"><p></p></td></tr></tbody></table>')
|
||||
expect(html).to.equal(
|
||||
'<table style="min-width: 25px"><colgroup><col style="min-width: 25px"></colgroup><tbody><tr><td colspan="1" rowspan="1"><p></p></td></tr></tbody></table>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -72,7 +82,9 @@ context('/src/Nodes/Table/Vue/', () => {
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).to.equal('<table style="min-width: 25px"><colgroup><col></colgroup><tbody><tr><th colspan="1" rowspan="1"><p></p></th></tr></tbody></table>')
|
||||
expect(html).to.equal(
|
||||
'<table style="min-width: 25px"><colgroup><col style="min-width: 25px"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p></p></th></tr></tbody></table>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -1,11 +0,0 @@
|
||||
# Editor API Utility Overview
|
||||
|
||||
Welcome to the Editor API Utility section. Here, you'll discover essential tools to enhance your Tiptap experience:
|
||||
|
||||
- **Render JSON as HTML**: Learn to convert JSON content to HTML, even without an editor instance, simplifying content management.
|
||||
|
||||
- **Tiptap for PHP**: Explore PHP integration for Tiptap, enabling seamless content transformation and modification.
|
||||
|
||||
- **Suggestions**: Enhance your editor with suggestions like mentions and emojis, tailored to your needs.
|
||||
|
||||
Explore subpages for in-depth guidance and examples.
|
5320
package-lock.json
generated
5320
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -13,7 +13,8 @@ import { CommandManager } from './CommandManager.js'
|
||||
import { EventEmitter } from './EventEmitter.js'
|
||||
import { ExtensionManager } from './ExtensionManager.js'
|
||||
import {
|
||||
ClipboardTextSerializer, Commands, Editable, FocusEvents, Keymap, Tabindex,
|
||||
ClipboardTextSerializer, Commands, Drop, Editable, FocusEvents, Keymap, Paste,
|
||||
Tabindex,
|
||||
} from './extensions/index.js'
|
||||
import { createDocument } from './helpers/createDocument.js'
|
||||
import { getAttributes } from './helpers/getAttributes.js'
|
||||
@ -24,8 +25,6 @@ import { isActive } from './helpers/isActive.js'
|
||||
import { isNodeEmpty } from './helpers/isNodeEmpty.js'
|
||||
import { resolveFocusPosition } from './helpers/resolveFocusPosition.js'
|
||||
import { NodePos } from './NodePos.js'
|
||||
import { DropPlugin } from './plugins/DropPlugin.js'
|
||||
import { PastePlugin } from './plugins/PastePlugin.js'
|
||||
import { style } from './style.js'
|
||||
import {
|
||||
CanCommands,
|
||||
@ -117,14 +116,8 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
this.on('focus', this.options.onFocus)
|
||||
this.on('blur', this.options.onBlur)
|
||||
this.on('destroy', this.options.onDestroy)
|
||||
|
||||
if (this.options.onPaste) {
|
||||
this.registerPlugin(PastePlugin(this.options.onPaste))
|
||||
}
|
||||
|
||||
if (this.options.onDrop) {
|
||||
this.registerPlugin(DropPlugin(this.options.onDrop))
|
||||
}
|
||||
this.on('drop', ({ event, slice, moved }) => this.options.onDrop(event, slice, moved))
|
||||
this.on('paste', ({ event, slice }) => this.options.onPaste(event, slice))
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (this.isDestroyed) {
|
||||
@ -249,20 +242,32 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
/**
|
||||
* Unregister a ProseMirror plugin.
|
||||
*
|
||||
* @param nameOrPluginKey The plugins name
|
||||
* @param nameOrPluginKeyToRemove The plugins name
|
||||
* @returns The new editor state or undefined if the editor is destroyed
|
||||
*/
|
||||
public unregisterPlugin(nameOrPluginKey: string | PluginKey): EditorState | undefined {
|
||||
public unregisterPlugin(nameOrPluginKeyToRemove: string | PluginKey | (string | PluginKey)[]): EditorState | undefined {
|
||||
if (this.isDestroyed) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const name = typeof nameOrPluginKey === 'string' ? `${nameOrPluginKey}$` : nameOrPluginKey.key
|
||||
const prevPlugins = this.state.plugins
|
||||
let plugins = prevPlugins;
|
||||
|
||||
([] as (string | PluginKey)[]).concat(nameOrPluginKeyToRemove).forEach(nameOrPluginKey => {
|
||||
// @ts-ignore
|
||||
const name = typeof nameOrPluginKey === 'string' ? `${nameOrPluginKey}$` : nameOrPluginKey.key
|
||||
|
||||
// @ts-ignore
|
||||
plugins = prevPlugins.filter(plugin => !plugin.key.startsWith(name))
|
||||
})
|
||||
|
||||
if (prevPlugins.length === plugins.length) {
|
||||
// No plugin was removed, so we don’t need to update the state
|
||||
return undefined
|
||||
}
|
||||
|
||||
const state = this.state.reconfigure({
|
||||
// @ts-ignore
|
||||
plugins: this.state.plugins.filter(plugin => !plugin.key.startsWith(name)),
|
||||
plugins,
|
||||
})
|
||||
|
||||
this.view.updateState(state)
|
||||
@ -284,6 +289,8 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
FocusEvents,
|
||||
Keymap,
|
||||
Tabindex,
|
||||
Drop,
|
||||
Paste,
|
||||
].filter(ext => {
|
||||
if (typeof this.options.enableCoreExtensions === 'object') {
|
||||
return this.options.enableCoreExtensions[ext.name as keyof typeof this.options.enableCoreExtensions] !== false
|
||||
@ -335,6 +342,9 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
editor: this,
|
||||
error: e as Error,
|
||||
disableCollaboration: () => {
|
||||
if (this.storage.collaboration) {
|
||||
this.storage.collaboration.isDisabled = true
|
||||
}
|
||||
// To avoid syncing back invalid content, reinitialize the extensions without the collaboration extension
|
||||
this.options.extensions = this.options.extensions.filter(extension => extension.name !== 'collaboration')
|
||||
|
||||
@ -362,6 +372,14 @@ export class Editor extends EventEmitter<EditorEvents> {
|
||||
}),
|
||||
})
|
||||
|
||||
// add `role="textbox"` to the editor element
|
||||
this.view.dom.setAttribute('role', 'textbox')
|
||||
|
||||
// add aria-label to the editor element
|
||||
if (!this.view.dom.getAttribute('aria-label')) {
|
||||
this.view.dom.setAttribute('aria-label', 'Rich-Text Editor')
|
||||
}
|
||||
|
||||
// `editor.view` is not yet available at this time.
|
||||
// Therefore we will add all plugins and node views directly afterwards.
|
||||
const newState = this.state.reconfigure({
|
||||
|
@ -61,7 +61,7 @@ declare module '@tiptap/core' {
|
||||
*/
|
||||
addOptions?: (this: {
|
||||
name: string
|
||||
parent: Exclude<ParentConfig<ExtensionConfig<Options, Storage>>['addOptions'], undefined>
|
||||
parent: ParentConfig<ExtensionConfig<Options, Storage>>['addOptions']
|
||||
}) => Options
|
||||
|
||||
/**
|
||||
@ -76,7 +76,7 @@ declare module '@tiptap/core' {
|
||||
addStorage?: (this: {
|
||||
name: string
|
||||
options: Options
|
||||
parent: Exclude<ParentConfig<ExtensionConfig<Options, Storage>>['addStorage'], undefined>
|
||||
parent: ParentConfig<ExtensionConfig<Options, Storage>>['addStorage']
|
||||
}) => Storage
|
||||
|
||||
/**
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { Fragment, Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { EditorState, Plugin, TextSelection } from '@tiptap/pm/state'
|
||||
|
||||
import { CommandManager } from './CommandManager.js'
|
||||
import { Editor } from './Editor.js'
|
||||
import { createChainableState } from './helpers/createChainableState.js'
|
||||
import { getHTMLFromFragment } from './helpers/getHTMLFromFragment.js'
|
||||
import { getTextContentFromNodes } from './helpers/getTextContentFromNodes.js'
|
||||
import {
|
||||
CanCommands,
|
||||
@ -14,37 +16,37 @@ import {
|
||||
import { isRegExp } from './utilities/isRegExp.js'
|
||||
|
||||
export type InputRuleMatch = {
|
||||
index: number
|
||||
text: string
|
||||
replaceWith?: string
|
||||
match?: RegExpMatchArray
|
||||
data?: Record<string, any>
|
||||
}
|
||||
index: number;
|
||||
text: string;
|
||||
replaceWith?: string;
|
||||
match?: RegExpMatchArray;
|
||||
data?: Record<string, any>;
|
||||
};
|
||||
|
||||
export type InputRuleFinder = RegExp | ((text: string) => InputRuleMatch | null)
|
||||
export type InputRuleFinder = RegExp | ((text: string) => InputRuleMatch | null);
|
||||
|
||||
export class InputRule {
|
||||
find: InputRuleFinder
|
||||
|
||||
handler: (props: {
|
||||
state: EditorState
|
||||
range: Range
|
||||
match: ExtendedRegExpMatchArray
|
||||
commands: SingleCommands
|
||||
chain: () => ChainedCommands
|
||||
can: () => CanCommands
|
||||
state: EditorState;
|
||||
range: Range;
|
||||
match: ExtendedRegExpMatchArray;
|
||||
commands: SingleCommands;
|
||||
chain: () => ChainedCommands;
|
||||
can: () => CanCommands;
|
||||
}) => void | null
|
||||
|
||||
constructor(config: {
|
||||
find: InputRuleFinder
|
||||
find: InputRuleFinder;
|
||||
handler: (props: {
|
||||
state: EditorState
|
||||
range: Range
|
||||
match: ExtendedRegExpMatchArray
|
||||
commands: SingleCommands
|
||||
chain: () => ChainedCommands
|
||||
can: () => CanCommands
|
||||
}) => void | null
|
||||
state: EditorState;
|
||||
range: Range;
|
||||
match: ExtendedRegExpMatchArray;
|
||||
commands: SingleCommands;
|
||||
chain: () => ChainedCommands;
|
||||
can: () => CanCommands;
|
||||
}) => void | null;
|
||||
}) {
|
||||
this.find = config.find
|
||||
this.handler = config.handler
|
||||
@ -85,12 +87,12 @@ const inputRuleMatcherHandler = (
|
||||
}
|
||||
|
||||
function run(config: {
|
||||
editor: Editor
|
||||
from: number
|
||||
to: number
|
||||
text: string
|
||||
rules: InputRule[]
|
||||
plugin: Plugin
|
||||
editor: Editor;
|
||||
from: number;
|
||||
to: number;
|
||||
text: string;
|
||||
rules: InputRule[];
|
||||
plugin: Plugin;
|
||||
}): boolean {
|
||||
const {
|
||||
editor, from, to, text, rules, plugin,
|
||||
@ -184,7 +186,7 @@ export function inputRulesPlugin(props: { editor: Editor; rules: InputRule[] }):
|
||||
init() {
|
||||
return null
|
||||
},
|
||||
apply(tr, prev) {
|
||||
apply(tr, prev, state) {
|
||||
const stored = tr.getMeta(plugin)
|
||||
|
||||
if (stored) {
|
||||
@ -192,12 +194,25 @@ export function inputRulesPlugin(props: { editor: Editor; rules: InputRule[] }):
|
||||
}
|
||||
|
||||
// if InputRule is triggered by insertContent()
|
||||
const simulatedInputMeta = tr.getMeta('applyInputRules')
|
||||
const simulatedInputMeta = tr.getMeta('applyInputRules') as
|
||||
| undefined
|
||||
| {
|
||||
from: number;
|
||||
text: string | ProseMirrorNode | Fragment;
|
||||
}
|
||||
const isSimulatedInput = !!simulatedInputMeta
|
||||
|
||||
if (isSimulatedInput) {
|
||||
setTimeout(() => {
|
||||
const { from, text } = simulatedInputMeta
|
||||
let { text } = simulatedInputMeta
|
||||
|
||||
if (typeof text === 'string') {
|
||||
text = text as string
|
||||
} else {
|
||||
text = getHTMLFromFragment(Fragment.from(text), state.schema)
|
||||
}
|
||||
|
||||
const { from } = simulatedInputMeta
|
||||
const to = from + text.length
|
||||
|
||||
run({
|
||||
|
@ -64,7 +64,7 @@ declare module '@tiptap/core' {
|
||||
*/
|
||||
addOptions?: (this: {
|
||||
name: string
|
||||
parent: Exclude<ParentConfig<MarkConfig<Options, Storage>>['addOptions'], undefined>
|
||||
parent: ParentConfig<MarkConfig<Options, Storage>>['addOptions']
|
||||
}) => Options
|
||||
|
||||
/**
|
||||
@ -79,7 +79,7 @@ declare module '@tiptap/core' {
|
||||
addStorage?: (this: {
|
||||
name: string
|
||||
options: Options
|
||||
parent: Exclude<ParentConfig<MarkConfig<Options, Storage>>['addStorage'], undefined>
|
||||
parent: ParentConfig<MarkConfig<Options, Storage>>['addStorage']
|
||||
}) => Storage
|
||||
|
||||
/**
|
||||
|
@ -65,7 +65,7 @@ declare module '@tiptap/core' {
|
||||
*/
|
||||
addOptions?: (this: {
|
||||
name: string
|
||||
parent: Exclude<ParentConfig<NodeConfig<Options, Storage>>['addOptions'], undefined>
|
||||
parent: ParentConfig<NodeConfig<Options, Storage>>['addOptions']
|
||||
}) => Options
|
||||
|
||||
/**
|
||||
@ -80,7 +80,7 @@ declare module '@tiptap/core' {
|
||||
addStorage?: (this: {
|
||||
name: string
|
||||
options: Options
|
||||
parent: Exclude<ParentConfig<NodeConfig<Options, Storage>>['addStorage'], undefined>
|
||||
parent: ParentConfig<NodeConfig<Options, Storage>>['addStorage']
|
||||
}) => Storage
|
||||
|
||||
/**
|
||||
|
@ -135,8 +135,9 @@ export class NodePos {
|
||||
|
||||
this.node.content.forEach((node, offset) => {
|
||||
const isBlock = node.isBlock && !node.isTextblock
|
||||
const isNonTextAtom = node.isAtom && !node.isText
|
||||
|
||||
const targetPos = this.pos + offset + 1
|
||||
const targetPos = this.pos + offset + (isNonTextAtom ? 0 : 1)
|
||||
const $pos = this.resolvedPos.doc.resolve(targetPos)
|
||||
|
||||
if (!isBlock && $pos.depth <= this.depth) {
|
||||
@ -235,9 +236,13 @@ export class NodePos {
|
||||
}
|
||||
|
||||
setAttribute(attributes: { [key: string]: any }) {
|
||||
const oldSelection = this.editor.state.selection
|
||||
const { tr } = this.editor.state
|
||||
|
||||
this.editor.chain().setTextSelection(this.from).updateAttributes(this.node.type.name, attributes).setTextSelection(oldSelection.from)
|
||||
.run()
|
||||
tr.setNodeMarkup(this.from, undefined, {
|
||||
...this.node.attrs,
|
||||
...attributes,
|
||||
})
|
||||
|
||||
this.editor.view.dispatch(tr)
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { Fragment, Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { EditorState, Plugin } from '@tiptap/pm/state'
|
||||
|
||||
import { CommandManager } from './CommandManager.js'
|
||||
import { Editor } from './Editor.js'
|
||||
import { createChainableState } from './helpers/createChainableState.js'
|
||||
import { getHTMLFromFragment } from './helpers/getHTMLFromFragment.js'
|
||||
import {
|
||||
CanCommands,
|
||||
ChainedCommands,
|
||||
@ -14,45 +16,47 @@ import { isNumber } from './utilities/isNumber.js'
|
||||
import { isRegExp } from './utilities/isRegExp.js'
|
||||
|
||||
export type PasteRuleMatch = {
|
||||
index: number
|
||||
text: string
|
||||
replaceWith?: string
|
||||
match?: RegExpMatchArray
|
||||
data?: Record<string, any>
|
||||
}
|
||||
index: number;
|
||||
text: string;
|
||||
replaceWith?: string;
|
||||
match?: RegExpMatchArray;
|
||||
data?: Record<string, any>;
|
||||
};
|
||||
|
||||
export type PasteRuleFinder = RegExp | ((text: string, event?: ClipboardEvent | null) => PasteRuleMatch[] | null | undefined)
|
||||
export type PasteRuleFinder =
|
||||
| RegExp
|
||||
| ((text: string, event?: ClipboardEvent | null) => PasteRuleMatch[] | null | undefined);
|
||||
|
||||
/**
|
||||
* Paste rules are used to react to pasted content.
|
||||
* @see https://tiptap.dev/guide/custom-extensions/#paste-rules
|
||||
* @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#paste-rules
|
||||
*/
|
||||
export class PasteRule {
|
||||
find: PasteRuleFinder
|
||||
|
||||
handler: (props: {
|
||||
state: EditorState
|
||||
range: Range
|
||||
match: ExtendedRegExpMatchArray
|
||||
commands: SingleCommands
|
||||
chain: () => ChainedCommands
|
||||
can: () => CanCommands
|
||||
pasteEvent: ClipboardEvent | null
|
||||
dropEvent: DragEvent | null
|
||||
state: EditorState;
|
||||
range: Range;
|
||||
match: ExtendedRegExpMatchArray;
|
||||
commands: SingleCommands;
|
||||
chain: () => ChainedCommands;
|
||||
can: () => CanCommands;
|
||||
pasteEvent: ClipboardEvent | null;
|
||||
dropEvent: DragEvent | null;
|
||||
}) => void | null
|
||||
|
||||
constructor(config: {
|
||||
find: PasteRuleFinder
|
||||
find: PasteRuleFinder;
|
||||
handler: (props: {
|
||||
can: () => CanCommands
|
||||
chain: () => ChainedCommands
|
||||
commands: SingleCommands
|
||||
dropEvent: DragEvent | null
|
||||
match: ExtendedRegExpMatchArray
|
||||
pasteEvent: ClipboardEvent | null
|
||||
range: Range
|
||||
state: EditorState
|
||||
}) => void | null
|
||||
can: () => CanCommands;
|
||||
chain: () => ChainedCommands;
|
||||
commands: SingleCommands;
|
||||
dropEvent: DragEvent | null;
|
||||
match: ExtendedRegExpMatchArray;
|
||||
pasteEvent: ClipboardEvent | null;
|
||||
range: Range;
|
||||
state: EditorState;
|
||||
}) => void | null;
|
||||
}) {
|
||||
this.find = config.find
|
||||
this.handler = config.handler
|
||||
@ -96,13 +100,13 @@ const pasteRuleMatcherHandler = (
|
||||
}
|
||||
|
||||
function run(config: {
|
||||
editor: Editor
|
||||
state: EditorState
|
||||
from: number
|
||||
to: number
|
||||
rule: PasteRule
|
||||
pasteEvent: ClipboardEvent | null
|
||||
dropEvent: DragEvent | null
|
||||
editor: Editor;
|
||||
state: EditorState;
|
||||
from: number;
|
||||
to: number;
|
||||
rule: PasteRule;
|
||||
pasteEvent: ClipboardEvent | null;
|
||||
dropEvent: DragEvent | null;
|
||||
}): boolean {
|
||||
const {
|
||||
editor, state, from, to, rule, pasteEvent, dropEvent,
|
||||
@ -179,7 +183,13 @@ export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }):
|
||||
let isPastedFromProseMirror = false
|
||||
let isDroppedFromProseMirror = false
|
||||
let pasteEvent = typeof ClipboardEvent !== 'undefined' ? new ClipboardEvent('paste') : null
|
||||
let dropEvent = typeof DragEvent !== 'undefined' ? new DragEvent('drop') : null
|
||||
let dropEvent: DragEvent | null
|
||||
|
||||
try {
|
||||
dropEvent = typeof DragEvent !== 'undefined' ? new DragEvent('drop') : null
|
||||
} catch (e) {
|
||||
dropEvent = null
|
||||
}
|
||||
|
||||
const processEvent = ({
|
||||
state,
|
||||
@ -188,11 +198,11 @@ export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }):
|
||||
rule,
|
||||
pasteEvt,
|
||||
}: {
|
||||
state: EditorState
|
||||
from: number
|
||||
to: { b: number }
|
||||
rule: PasteRule
|
||||
pasteEvt: ClipboardEvent | null
|
||||
state: EditorState;
|
||||
from: number;
|
||||
to: { b: number };
|
||||
rule: PasteRule;
|
||||
pasteEvt: ClipboardEvent | null;
|
||||
}) => {
|
||||
const tr = state.tr
|
||||
const chainableState = createChainableState({
|
||||
@ -214,7 +224,11 @@ export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }):
|
||||
return
|
||||
}
|
||||
|
||||
dropEvent = typeof DragEvent !== 'undefined' ? new DragEvent('drop') : null
|
||||
try {
|
||||
dropEvent = typeof DragEvent !== 'undefined' ? new DragEvent('drop') : null
|
||||
} catch (e) {
|
||||
dropEvent = null
|
||||
}
|
||||
pasteEvent = typeof ClipboardEvent !== 'undefined' ? new ClipboardEvent('paste') : null
|
||||
|
||||
return tr
|
||||
@ -266,7 +280,9 @@ export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }):
|
||||
const isDrop = transaction.getMeta('uiEvent') === 'drop' && !isDroppedFromProseMirror
|
||||
|
||||
// if PasteRule is triggered by insertContent()
|
||||
const simulatedPasteMeta = transaction.getMeta('applyPasteRules')
|
||||
const simulatedPasteMeta = transaction.getMeta('applyPasteRules') as
|
||||
| undefined
|
||||
| { from: number; text: string | ProseMirrorNode | Fragment }
|
||||
const isSimulatedPaste = !!simulatedPasteMeta
|
||||
|
||||
if (!isPaste && !isDrop && !isSimulatedPaste) {
|
||||
@ -275,8 +291,17 @@ export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }):
|
||||
|
||||
// Handle simulated paste
|
||||
if (isSimulatedPaste) {
|
||||
const { from, text } = simulatedPasteMeta
|
||||
let { text } = simulatedPasteMeta
|
||||
|
||||
if (typeof text === 'string') {
|
||||
text = text as string
|
||||
} else {
|
||||
text = getHTMLFromFragment(Fragment.from(text), state.schema)
|
||||
}
|
||||
|
||||
const { from } = simulatedPasteMeta
|
||||
const to = from + text.length
|
||||
|
||||
const pasteEvt = createClipboardPasteEvent(text)
|
||||
|
||||
return processEvent({
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ParseOptions } from '@tiptap/pm/model'
|
||||
import { Fragment, Node as ProseMirrorNode, ParseOptions } from '@tiptap/pm/model'
|
||||
|
||||
import { Content, RawCommands } from '../types.js'
|
||||
|
||||
@ -14,7 +14,7 @@ declare module '@tiptap/core' {
|
||||
/**
|
||||
* The ProseMirror content to insert.
|
||||
*/
|
||||
value: Content,
|
||||
value: Content | ProseMirrorNode | Fragment,
|
||||
|
||||
/**
|
||||
* Optional options
|
||||
@ -23,17 +23,17 @@ declare module '@tiptap/core' {
|
||||
/**
|
||||
* Options for parsing the content.
|
||||
*/
|
||||
parseOptions?: ParseOptions
|
||||
parseOptions?: ParseOptions;
|
||||
|
||||
/**
|
||||
* Whether to update the selection after inserting the content.
|
||||
*/
|
||||
updateSelection?: boolean
|
||||
applyInputRules?: boolean
|
||||
applyPasteRules?: boolean
|
||||
},
|
||||
) => ReturnType
|
||||
}
|
||||
updateSelection?: boolean;
|
||||
applyInputRules?: boolean;
|
||||
applyPasteRules?: boolean;
|
||||
}
|
||||
) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -20,7 +20,7 @@ declare module '@tiptap/core' {
|
||||
/**
|
||||
* The ProseMirror content to insert.
|
||||
*/
|
||||
value: Content,
|
||||
value: Content | ProseMirrorNode | Fragment,
|
||||
|
||||
/**
|
||||
* Optional options
|
||||
@ -63,7 +63,7 @@ const isFragment = (nodeOrFragment: ProseMirrorNode | Fragment): nodeOrFragment
|
||||
export const insertContentAt: RawCommands['insertContentAt'] = (position, value, options) => ({ tr, dispatch, editor }) => {
|
||||
if (dispatch) {
|
||||
options = {
|
||||
parseOptions: {},
|
||||
parseOptions: editor.options.parseOptions,
|
||||
updateSelection: true,
|
||||
applyInputRules: false,
|
||||
applyPasteRules: false,
|
||||
@ -86,7 +86,9 @@ export const insertContentAt: RawCommands['insertContentAt'] = (position, value,
|
||||
editor,
|
||||
error: e as Error,
|
||||
disableCollaboration: () => {
|
||||
console.error('[tiptap error]: Unable to disable collaboration at this point in time')
|
||||
if (editor.storage.collaboration) {
|
||||
editor.storage.collaboration.isDisabled = true
|
||||
}
|
||||
},
|
||||
})
|
||||
return false
|
||||
@ -131,6 +133,16 @@ export const insertContentAt: RawCommands['insertContentAt'] = (position, value,
|
||||
// otherwise if it is an array, we have to join it
|
||||
if (Array.isArray(value)) {
|
||||
newContent = value.map(v => v.text || '').join('')
|
||||
} else if (value instanceof Fragment) {
|
||||
let text = ''
|
||||
|
||||
value.forEach(node => {
|
||||
if (node.text) {
|
||||
text += node.text
|
||||
}
|
||||
})
|
||||
|
||||
newContent = text
|
||||
} else if (typeof value === 'object' && !!value && !!value.text) {
|
||||
newContent = value.text
|
||||
} else {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ParseOptions } from '@tiptap/pm/model'
|
||||
import { Fragment, Node as ProseMirrorNode, ParseOptions } from '@tiptap/pm/model'
|
||||
|
||||
import { createDocument } from '../helpers/createDocument.js'
|
||||
import { Content, RawCommands } from '../types.js'
|
||||
@ -17,7 +17,7 @@ declare module '@tiptap/core' {
|
||||
/**
|
||||
* The new content.
|
||||
*/
|
||||
content: Content,
|
||||
content: Content | Fragment | ProseMirrorNode,
|
||||
|
||||
/**
|
||||
* Whether to emit an update event.
|
||||
@ -37,10 +37,10 @@ declare module '@tiptap/core' {
|
||||
/**
|
||||
* Whether to throw an error if the content is invalid.
|
||||
*/
|
||||
errorOnInvalidContent?: boolean
|
||||
},
|
||||
) => ReturnType
|
||||
}
|
||||
errorOnInvalidContent?: boolean;
|
||||
}
|
||||
) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,12 +66,8 @@ export const setContent: RawCommands['setContent'] = (content, emitUpdate = fals
|
||||
tr.setMeta('preventUpdate', !emitUpdate)
|
||||
}
|
||||
|
||||
return commands.insertContentAt(
|
||||
{ from: 0, to: doc.content.size },
|
||||
content,
|
||||
{
|
||||
parseOptions,
|
||||
errorOnInvalidContent: options.errorOnInvalidContent ?? editor.options.enableContentCheck,
|
||||
},
|
||||
)
|
||||
return commands.insertContentAt({ from: 0, to: doc.content.size }, content, {
|
||||
parseOptions,
|
||||
errorOnInvalidContent: options.errorOnInvalidContent ?? editor.options.enableContentCheck,
|
||||
})
|
||||
}
|
||||
|
@ -21,6 +21,13 @@ declare module '@tiptap/core' {
|
||||
export const setNode: RawCommands['setNode'] = (typeOrName, attributes = {}) => ({ state, dispatch, chain }) => {
|
||||
const type = getNodeType(typeOrName, state.schema)
|
||||
|
||||
let attributesToCopy: Record<string, any> | undefined
|
||||
|
||||
if (state.selection.$anchor.sameParent(state.selection.$head)) {
|
||||
// only copy attributes if the selection is pointing to a node of the same type
|
||||
attributesToCopy = state.selection.$anchor.parent.attrs
|
||||
}
|
||||
|
||||
// TODO: use a fallback like insertContent?
|
||||
if (!type.isTextblock) {
|
||||
console.warn('[tiptap warn]: Currently "setNode()" only supports text block nodes.')
|
||||
@ -32,7 +39,7 @@ export const setNode: RawCommands['setNode'] = (typeOrName, attributes = {}) =>
|
||||
chain()
|
||||
// try to convert node to default node if needed
|
||||
.command(({ commands }) => {
|
||||
const canSetBlock = setBlockType(type, attributes)(state)
|
||||
const canSetBlock = setBlockType(type, { ...attributesToCopy, ...attributes })(state)
|
||||
|
||||
if (canSetBlock) {
|
||||
return true
|
||||
@ -41,7 +48,7 @@ export const setNode: RawCommands['setNode'] = (typeOrName, attributes = {}) =>
|
||||
return commands.clearNodes()
|
||||
})
|
||||
.command(({ state: updatedState }) => {
|
||||
return setBlockType(type, attributes)(updatedState, dispatch)
|
||||
return setBlockType(type, { ...attributesToCopy, ...attributes })(updatedState, dispatch)
|
||||
})
|
||||
.run()
|
||||
)
|
||||
|
26
packages/core/src/extensions/drop.ts
Normal file
26
packages/core/src/extensions/drop.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
import { Extension } from '../Extension.js'
|
||||
|
||||
export const Drop = Extension.create({
|
||||
name: 'drop',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('tiptapDrop'),
|
||||
|
||||
props: {
|
||||
handleDrop: (_, e, slice, moved) => {
|
||||
this.editor.emit('drop', {
|
||||
editor: this.editor,
|
||||
event: e,
|
||||
slice,
|
||||
moved,
|
||||
})
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
@ -1,6 +1,8 @@
|
||||
export { ClipboardTextSerializer } from './clipboardTextSerializer.js'
|
||||
export { Commands } from './commands.js'
|
||||
export { Drop } from './drop.js'
|
||||
export { Editable } from './editable.js'
|
||||
export { FocusEvents } from './focusEvents.js'
|
||||
export { Keymap } from './keymap.js'
|
||||
export { Paste } from './paste.js'
|
||||
export { Tabindex } from './tabindex.js'
|
||||
|
26
packages/core/src/extensions/paste.ts
Normal file
26
packages/core/src/extensions/paste.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
import { Extension } from '../Extension.js'
|
||||
|
||||
export const Paste = Extension.create({
|
||||
name: 'paste',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('tiptapPaste'),
|
||||
|
||||
props: {
|
||||
handlePaste: (_view, e, slice) => {
|
||||
this.editor.emit('paste', {
|
||||
editor: this.editor,
|
||||
event: e,
|
||||
slice,
|
||||
})
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
@ -1,4 +1,6 @@
|
||||
import { Node as ProseMirrorNode, ParseOptions, Schema } from '@tiptap/pm/model'
|
||||
import {
|
||||
Fragment, Node as ProseMirrorNode, ParseOptions, Schema,
|
||||
} from '@tiptap/pm/model'
|
||||
|
||||
import { Content } from '../types.js'
|
||||
import { createNodeFromContent } from './createNodeFromContent.js'
|
||||
@ -11,7 +13,7 @@ import { createNodeFromContent } from './createNodeFromContent.js'
|
||||
* @returns The created Prosemirror document node
|
||||
*/
|
||||
export function createDocument(
|
||||
content: Content,
|
||||
content: Content | ProseMirrorNode | Fragment,
|
||||
schema: Schema,
|
||||
parseOptions: ParseOptions = {},
|
||||
options: { errorOnInvalidContent?: boolean } = {},
|
||||
|
@ -23,10 +23,13 @@ export type CreateNodeFromContentOptions = {
|
||||
* @returns The created Prosemirror node or fragment
|
||||
*/
|
||||
export function createNodeFromContent(
|
||||
content: Content,
|
||||
content: Content | ProseMirrorNode | Fragment,
|
||||
schema: Schema,
|
||||
options?: CreateNodeFromContentOptions,
|
||||
): ProseMirrorNode | Fragment {
|
||||
if (content instanceof ProseMirrorNode || content instanceof Fragment) {
|
||||
return content
|
||||
}
|
||||
options = {
|
||||
slice: true,
|
||||
parseOptions: {},
|
||||
|
@ -29,17 +29,20 @@ export function getMarkRange(
|
||||
if (!$pos || !type) {
|
||||
return
|
||||
}
|
||||
|
||||
let start = $pos.parent.childAfter($pos.parentOffset)
|
||||
|
||||
if ($pos.parentOffset === start.offset && start.offset !== 0) {
|
||||
// If the cursor is at the start of a text node that does not have the mark, look backward
|
||||
if (!start.node || !start.node.marks.some(mark => mark.type === type)) {
|
||||
start = $pos.parent.childBefore($pos.parentOffset)
|
||||
}
|
||||
|
||||
if (!start.node) {
|
||||
// If there is no text node with the mark even backward, return undefined
|
||||
if (!start.node || !start.node.marks.some(mark => mark.type === type)) {
|
||||
return
|
||||
}
|
||||
|
||||
// We now know that the cursor is either at the start, middle or end of a text node with the specified mark
|
||||
// so we can look it up on the targeted mark
|
||||
const mark = findMarkInSet([...start.node.marks], type, attributes)
|
||||
|
||||
if (!mark) {
|
||||
|
@ -11,8 +11,6 @@ export * from './NodePos.js'
|
||||
export * from './NodeView.js'
|
||||
export * from './PasteRule.js'
|
||||
export * from './pasteRules/index.js'
|
||||
export * from './plugins/DropPlugin.js'
|
||||
export * from './plugins/PastePlugin.js'
|
||||
export * from './Tracker.js'
|
||||
export * from './types.js'
|
||||
export * from './utilities/index.js'
|
||||
|
@ -8,7 +8,7 @@ import { callOrReturn } from '../utilities/callOrReturn.js'
|
||||
/**
|
||||
* Build an input rule that adds a mark when the
|
||||
* matched text is typed into it.
|
||||
* @see https://tiptap.dev/guide/custom-extensions/#input-rules
|
||||
* @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#input-rules
|
||||
*/
|
||||
export function markInputRule(config: {
|
||||
find: InputRuleFinder
|
||||
|
@ -7,7 +7,7 @@ import { callOrReturn } from '../utilities/callOrReturn.js'
|
||||
/**
|
||||
* Build an input rule that adds a node when the
|
||||
* matched text is typed into it.
|
||||
* @see https://tiptap.dev/guide/custom-extensions/#input-rules
|
||||
* @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#input-rules
|
||||
*/
|
||||
export function nodeInputRule(config: {
|
||||
/**
|
||||
|
@ -3,7 +3,7 @@ import { InputRule, InputRuleFinder } from '../InputRule.js'
|
||||
/**
|
||||
* Build an input rule that replaces text when the
|
||||
* matched text is typed into it.
|
||||
* @see https://tiptap.dev/guide/custom-extensions/#input-rules
|
||||
* @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#input-rules
|
||||
*/
|
||||
export function textInputRule(config: {
|
||||
find: InputRuleFinder,
|
||||
|
@ -9,7 +9,7 @@ import { callOrReturn } from '../utilities/callOrReturn.js'
|
||||
* matched text is typed into it. When using a regular expresion you’ll
|
||||
* probably want the regexp to start with `^`, so that the pattern can
|
||||
* only occur at the start of a textblock.
|
||||
* @see https://tiptap.dev/guide/custom-extensions/#input-rules
|
||||
* @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#input-rules
|
||||
*/
|
||||
export function textblockTypeInputRule(config: {
|
||||
find: InputRuleFinder
|
||||
|
@ -19,7 +19,7 @@ import { callOrReturn } from '../utilities/callOrReturn.js'
|
||||
* two nodes. You can pass a join predicate, which takes a regular
|
||||
* expression match and the node before the wrapped node, and can
|
||||
* return a boolean to indicate whether a join should happen.
|
||||
* @see https://tiptap.dev/guide/custom-extensions/#input-rules
|
||||
* @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#input-rules
|
||||
*/
|
||||
export function wrappingInputRule(config: {
|
||||
find: InputRuleFinder,
|
||||
|
@ -8,7 +8,7 @@ import { callOrReturn } from '../utilities/callOrReturn.js'
|
||||
/**
|
||||
* Build an paste rule that adds a mark when the
|
||||
* matched text is pasted into it.
|
||||
* @see https://tiptap.dev/guide/custom-extensions/#paste-rules
|
||||
* @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#paste-rules
|
||||
*/
|
||||
export function markPasteRule(config: {
|
||||
find: PasteRuleFinder
|
||||
|
@ -7,7 +7,7 @@ import { callOrReturn } from '../utilities/index.js'
|
||||
/**
|
||||
* Build an paste rule that adds a node when the
|
||||
* matched text is pasted into it.
|
||||
* @see https://tiptap.dev/guide/custom-extensions/#paste-rules
|
||||
* @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#paste-rules
|
||||
*/
|
||||
export function nodePasteRule(config: {
|
||||
find: PasteRuleFinder
|
||||
|
@ -3,7 +3,7 @@ import { PasteRule, PasteRuleFinder } from '../PasteRule.js'
|
||||
/**
|
||||
* Build an paste rule that replaces text when the
|
||||
* matched text is pasted into it.
|
||||
* @see https://tiptap.dev/guide/custom-extensions/#paste-rules
|
||||
* @see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#paste-rules
|
||||
*/
|
||||
export function textPasteRule(config: {
|
||||
find: PasteRuleFinder,
|
||||
|
@ -1,14 +0,0 @@
|
||||
import { Slice } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
export const DropPlugin = (onDrop: (e: DragEvent, slice: Slice, moved: boolean) => void) => {
|
||||
return new Plugin({
|
||||
key: new PluginKey('tiptapDrop'),
|
||||
|
||||
props: {
|
||||
handleDrop: (_, e, slice, moved) => {
|
||||
onDrop(e, slice, moved)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
import { Slice } from '@tiptap/pm/model'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
export const PastePlugin = (onPaste: (e: ClipboardEvent, slice: Slice) => void) => {
|
||||
return new Plugin({
|
||||
key: new PluginKey('tiptapPaste'),
|
||||
|
||||
props: {
|
||||
handlePaste: (_view, e, slice) => {
|
||||
onPaste(e, slice)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
@ -1,13 +1,14 @@
|
||||
import {
|
||||
Mark as ProseMirrorMark,
|
||||
Node as ProseMirrorNode,
|
||||
NodeType,
|
||||
ParseOptions,
|
||||
Slice,
|
||||
} from '@tiptap/pm/model'
|
||||
import { EditorState, Transaction } from '@tiptap/pm/state'
|
||||
import { Mappable } from '@tiptap/pm/transform'
|
||||
import {
|
||||
Decoration,
|
||||
DecorationAttrs,
|
||||
EditorProps,
|
||||
EditorView,
|
||||
NodeView,
|
||||
@ -63,6 +64,8 @@ export interface EditorEvents {
|
||||
focus: { editor: Editor; event: FocusEvent; transaction: Transaction };
|
||||
blur: { editor: Editor; event: FocusEvent; transaction: Transaction };
|
||||
destroy: void;
|
||||
paste: { editor: Editor; event: ClipboardEvent; slice: Slice };
|
||||
drop: { editor: Editor; event: DragEvent; slice: Slice; moved: boolean };
|
||||
}
|
||||
|
||||
export type EnableRules = (AnyExtension | string)[] | boolean;
|
||||
@ -110,7 +113,9 @@ export interface EditorOptions {
|
||||
| 'commands'
|
||||
| 'focusEvents'
|
||||
| 'keymap'
|
||||
| 'tabindex',
|
||||
| 'tabindex'
|
||||
| 'drop'
|
||||
| 'paste',
|
||||
false
|
||||
>
|
||||
>;
|
||||
@ -220,8 +225,27 @@ export type ValuesOf<T> = T[keyof T];
|
||||
|
||||
export type KeysWithTypeOf<T, Type> = { [P in keyof T]: T[P] extends Type ? P : never }[keyof T];
|
||||
|
||||
export type DOMNode = InstanceType<typeof window.Node>
|
||||
|
||||
/**
|
||||
* prosemirror-view does not export the `type` property of `Decoration`.
|
||||
* So, this defines the `DecorationType` interface to include the `type` property.
|
||||
*/
|
||||
export interface DecorationType {
|
||||
spec: any
|
||||
map(mapping: Mappable, span: Decoration, offset: number, oldOffset: number): Decoration | null
|
||||
valid(node: Node, span: Decoration): boolean
|
||||
eq(other: DecorationType): boolean
|
||||
destroy(dom: DOMNode): void
|
||||
readonly attrs: DecorationAttrs
|
||||
}
|
||||
|
||||
/**
|
||||
* prosemirror-view does not export the `type` property of `Decoration`.
|
||||
* This adds the `type` property to the `Decoration` type.
|
||||
*/
|
||||
export type DecorationWithType = Decoration & {
|
||||
type: NodeType;
|
||||
type: DecorationType;
|
||||
};
|
||||
|
||||
export interface NodeViewProps extends NodeViewRendererProps {
|
||||
@ -242,14 +266,40 @@ export interface NodeViewRendererOptions {
|
||||
|
||||
export interface NodeViewRendererProps {
|
||||
// pass-through from prosemirror
|
||||
/**
|
||||
* The node that is being rendered.
|
||||
*/
|
||||
node: Parameters<NodeViewConstructor>[0];
|
||||
/**
|
||||
* The editor's view.
|
||||
*/
|
||||
view: Parameters<NodeViewConstructor>[1];
|
||||
/**
|
||||
* A function that can be called to get the node's current position in the document.
|
||||
*/
|
||||
getPos: Parameters<NodeViewConstructor>[2];
|
||||
/**
|
||||
* is an array of node or inline decorations that are active around the node.
|
||||
* They are automatically drawn in the normal way, and you will usually just want to ignore this, but they can also be used as a way to provide context information to the node view without adding it to the document itself.
|
||||
*/
|
||||
decorations: Parameters<NodeViewConstructor>[3];
|
||||
/**
|
||||
* holds the decorations for the node's content. You can safely ignore this if your view has no content or a contentDOM property, since the editor will draw the decorations on the content.
|
||||
* But if you, for example, want to create a nested editor with the content, it may make sense to provide it with the inner decorations.
|
||||
*/
|
||||
innerDecorations: Parameters<NodeViewConstructor>[4];
|
||||
// tiptap-specific
|
||||
/**
|
||||
* The editor instance.
|
||||
*/
|
||||
editor: Editor;
|
||||
/**
|
||||
* The extension that is responsible for the node.
|
||||
*/
|
||||
extension: Node;
|
||||
/**
|
||||
* The HTML attributes that should be added to the node's DOM element.
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
|
@ -75,6 +75,10 @@ export const Bold = Mark.create<BoldOptions>({
|
||||
tag: 'b',
|
||||
getAttrs: node => (node as HTMLElement).style.fontWeight !== 'normal' && null,
|
||||
},
|
||||
{
|
||||
style: 'font-weight=400',
|
||||
clearMark: mark => mark.type.name === this.name,
|
||||
},
|
||||
{
|
||||
style: 'font-weight',
|
||||
getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value as string) && null,
|
||||
|
@ -16,6 +16,18 @@ export interface CharacterCountOptions {
|
||||
* @example 'textSize'
|
||||
*/
|
||||
mode: 'textSize' | 'nodeSize'
|
||||
/**
|
||||
* The text counter function to use. Defaults to a simple character count.
|
||||
* @default (text) => text.length
|
||||
* @example (text) => [...new Intl.Segmenter().segment(text)].length
|
||||
*/
|
||||
textCounter: (text: string) => number
|
||||
/**
|
||||
* The word counter function to use. Defaults to a simple word count.
|
||||
* @default (text) => text.split(' ').filter(word => word !== '').length
|
||||
* @example (text) => text.split(/\s+/).filter(word => word !== '').length
|
||||
*/
|
||||
wordCounter: (text: string) => number
|
||||
}
|
||||
|
||||
export interface CharacterCountStorage {
|
||||
@ -46,6 +58,8 @@ export const CharacterCount = Extension.create<CharacterCountOptions, CharacterC
|
||||
return {
|
||||
limit: null,
|
||||
mode: 'textSize',
|
||||
textCounter: text => text.length,
|
||||
wordCounter: text => text.split(' ').filter(word => word !== '').length,
|
||||
}
|
||||
},
|
||||
|
||||
@ -64,7 +78,7 @@ export const CharacterCount = Extension.create<CharacterCountOptions, CharacterC
|
||||
if (mode === 'textSize') {
|
||||
const text = node.textBetween(0, node.content.size, undefined, ' ')
|
||||
|
||||
return text.length
|
||||
return this.options.textCounter(text)
|
||||
}
|
||||
|
||||
return node.nodeSize
|
||||
@ -73,9 +87,8 @@ export const CharacterCount = Extension.create<CharacterCountOptions, CharacterC
|
||||
this.storage.words = options => {
|
||||
const node = options?.node || this.editor.state.doc
|
||||
const text = node.textBetween(0, node.content.size, ' ', ' ')
|
||||
const words = text.split(' ').filter(word => word !== '')
|
||||
|
||||
return words.length
|
||||
return this.options.wordCounter(text)
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { EditorView } from '@tiptap/pm/view'
|
||||
import {
|
||||
redo,
|
||||
@ -6,10 +7,12 @@ import {
|
||||
ySyncPlugin,
|
||||
yUndoPlugin,
|
||||
yUndoPluginKey,
|
||||
yXmlFragmentToProsemirrorJSON,
|
||||
} from 'y-prosemirror'
|
||||
import { UndoManager } from 'yjs'
|
||||
import { Doc, UndoManager, XmlFragment } from 'yjs'
|
||||
|
||||
type YSyncOpts = Parameters<typeof ySyncPlugin>[1]
|
||||
type YSyncOpts = Parameters<typeof ySyncPlugin>[1];
|
||||
type YUndoOpts = Parameters<typeof yUndoPlugin>[0];
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
@ -18,49 +21,65 @@ declare module '@tiptap/core' {
|
||||
* Undo recent changes
|
||||
* @example editor.commands.undo()
|
||||
*/
|
||||
undo: () => ReturnType,
|
||||
undo: () => ReturnType;
|
||||
/**
|
||||
* Reapply reverted changes
|
||||
* @example editor.commands.redo()
|
||||
*/
|
||||
redo: () => ReturnType,
|
||||
}
|
||||
redo: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface CollaborationStorage {
|
||||
/**
|
||||
* Whether collaboration is currently disabled.
|
||||
* Disabling collaboration will prevent any changes from being synced with other users.
|
||||
*/
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export interface CollaborationOptions {
|
||||
/**
|
||||
* An initialized Y.js document.
|
||||
* @example new Y.Doc()
|
||||
*/
|
||||
document: any,
|
||||
document?: Doc | null;
|
||||
|
||||
/**
|
||||
* Name of a Y.js fragment, can be changed to sync multiple fields with one Y.js document.
|
||||
* @default 'default'
|
||||
* @example 'my-custom-field'
|
||||
*/
|
||||
field: string,
|
||||
field?: string;
|
||||
|
||||
/**
|
||||
* A raw Y.js fragment, can be used instead of `document` and `field`.
|
||||
* @example new Y.Doc().getXmlFragment('body')
|
||||
*/
|
||||
fragment: any,
|
||||
fragment?: XmlFragment | null;
|
||||
|
||||
/**
|
||||
* Fired when the content from Yjs is initially rendered to Tiptap.
|
||||
*/
|
||||
onFirstRender?: () => void,
|
||||
onFirstRender?: () => void;
|
||||
|
||||
ySyncOptions?: YSyncOpts
|
||||
/**
|
||||
* Options for the Yjs sync plugin.
|
||||
*/
|
||||
ySyncOptions?: YSyncOpts;
|
||||
|
||||
/**
|
||||
* Options for the Yjs undo plugin.
|
||||
*/
|
||||
yUndoOptions?: YUndoOpts;
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension allows you to collaborate with others in real-time.
|
||||
* @see https://tiptap.dev/api/extensions/collaboration
|
||||
*/
|
||||
export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
export const Collaboration = Extension.create<CollaborationOptions, CollaborationStorage>({
|
||||
name: 'collaboration',
|
||||
|
||||
priority: 1000,
|
||||
@ -73,44 +92,54 @@ export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
}
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
isDisabled: false,
|
||||
}
|
||||
},
|
||||
|
||||
onCreate() {
|
||||
if (this.editor.extensionManager.extensions.find(extension => extension.name === 'history')) {
|
||||
console.warn('[tiptap warn]: "@tiptap/extension-collaboration" comes with its own history support and is not compatible with "@tiptap/extension-history".')
|
||||
console.warn(
|
||||
'[tiptap warn]: "@tiptap/extension-collaboration" comes with its own history support and is not compatible with "@tiptap/extension-history".',
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
undo: () => ({ tr, state, dispatch }) => {
|
||||
tr.setMeta('preventDispatch', true)
|
||||
undo:
|
||||
() => ({ tr, state, dispatch }) => {
|
||||
tr.setMeta('preventDispatch', true)
|
||||
|
||||
const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager
|
||||
const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager
|
||||
|
||||
if (undoManager.undoStack.length === 0) {
|
||||
return false
|
||||
}
|
||||
if (undoManager.undoStack.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!dispatch) {
|
||||
return true
|
||||
}
|
||||
if (!dispatch) {
|
||||
return true
|
||||
}
|
||||
|
||||
return undo(state)
|
||||
},
|
||||
redo: () => ({ tr, state, dispatch }) => {
|
||||
tr.setMeta('preventDispatch', true)
|
||||
return undo(state)
|
||||
},
|
||||
redo:
|
||||
() => ({ tr, state, dispatch }) => {
|
||||
tr.setMeta('preventDispatch', true)
|
||||
|
||||
const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager
|
||||
const undoManager: UndoManager = yUndoPluginKey.getState(state).undoManager
|
||||
|
||||
if (undoManager.redoStack.length === 0) {
|
||||
return false
|
||||
}
|
||||
if (undoManager.redoStack.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!dispatch) {
|
||||
return true
|
||||
}
|
||||
if (!dispatch) {
|
||||
return true
|
||||
}
|
||||
|
||||
return redo(state)
|
||||
},
|
||||
return redo(state)
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@ -125,11 +154,11 @@ export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
addProseMirrorPlugins() {
|
||||
const fragment = this.options.fragment
|
||||
? this.options.fragment
|
||||
: this.options.document.getXmlFragment(this.options.field)
|
||||
: (this.options.document as Doc).getXmlFragment(this.options.field)
|
||||
|
||||
// Quick fix until there is an official implementation (thanks to @hamflx).
|
||||
// See https://github.com/yjs/y-prosemirror/issues/114 and https://github.com/yjs/y-prosemirror/issues/102
|
||||
const yUndoPluginInstance = yUndoPlugin()
|
||||
const yUndoPluginInstance = yUndoPlugin(this.options.yUndoOptions)
|
||||
const originalUndoPluginView = yUndoPluginInstance.spec.view
|
||||
|
||||
yUndoPluginInstance.spec.view = (view: EditorView) => {
|
||||
@ -137,8 +166,9 @@ export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
|
||||
if (undoManager.restore) {
|
||||
undoManager.restore()
|
||||
// eslint-disable-next-line
|
||||
undoManager.restore = () => {}
|
||||
undoManager.restore = () => {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
const viewRet = originalUndoPluginView ? originalUndoPluginView(view) : undefined
|
||||
@ -146,7 +176,7 @@ export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
return {
|
||||
destroy: () => {
|
||||
const hasUndoManSelf = undoManager.trackedOrigins.has(undoManager)
|
||||
// eslint-disable-next-line
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const observers = undoManager._observers
|
||||
|
||||
undoManager.restore = () => {
|
||||
@ -155,7 +185,7 @@ export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
}
|
||||
|
||||
undoManager.doc.on('afterTransaction', undoManager.afterTransactionHandler)
|
||||
// eslint-disable-next-line
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
undoManager._observers = observers
|
||||
}
|
||||
|
||||
@ -173,6 +203,50 @@ export const Collaboration = Extension.create<CollaborationOptions>({
|
||||
|
||||
const ySyncPluginInstance = ySyncPlugin(fragment, ySyncPluginOptions)
|
||||
|
||||
return [ySyncPluginInstance, yUndoPluginInstance]
|
||||
if (this.editor.options.enableContentCheck) {
|
||||
fragment.doc?.on('beforeTransaction', () => {
|
||||
try {
|
||||
const jsonContent = (yXmlFragmentToProsemirrorJSON(fragment))
|
||||
|
||||
if (jsonContent.content.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.editor.schema.nodeFromJSON(jsonContent).check()
|
||||
} catch (error) {
|
||||
this.editor.emit('contentError', {
|
||||
error: error as Error,
|
||||
editor: this.editor,
|
||||
disableCollaboration: () => {
|
||||
fragment.doc?.destroy()
|
||||
this.storage.isDisabled = true
|
||||
},
|
||||
})
|
||||
// If the content is invalid, return false to prevent the transaction from being applied
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return [
|
||||
ySyncPluginInstance,
|
||||
yUndoPluginInstance,
|
||||
// Only add the filterInvalidContent plugin if content checking is enabled
|
||||
this.editor.options.enableContentCheck
|
||||
&& new Plugin({
|
||||
key: new PluginKey('filterInvalidContent'),
|
||||
filterTransaction: () => {
|
||||
// When collaboration is disabled, prevent any sync transactions from being applied
|
||||
if (this.storage.isDisabled) {
|
||||
// Destroy the Yjs document to prevent any further sync transactions
|
||||
fragment.doc?.destroy()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
}),
|
||||
].filter(Boolean)
|
||||
},
|
||||
})
|
||||
|
@ -78,6 +78,10 @@ export const Italic = Mark.create<ItalicOptions>({
|
||||
tag: 'i',
|
||||
getAttrs: node => (node as HTMLElement).style.fontStyle !== 'normal' && null,
|
||||
},
|
||||
{
|
||||
style: 'font-style=normal',
|
||||
clearMark: mark => mark.type.name === this.name,
|
||||
},
|
||||
{
|
||||
style: 'font-style=italic',
|
||||
},
|
||||
|
@ -77,6 +77,8 @@ export const MentionPluginKey = new PluginKey('mention')
|
||||
export const Mention = Node.create<MentionOptions>({
|
||||
name: 'mention',
|
||||
|
||||
priority: 101,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
|
@ -37,7 +37,7 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||
parseHTML: element => {
|
||||
const colwidth = element.getAttribute('colwidth')
|
||||
const value = colwidth
|
||||
? [parseInt(colwidth, 10)]
|
||||
? colwidth.split(',').map(width => parseInt(width, 10))
|
||||
: null
|
||||
|
||||
return value
|
||||
|
@ -37,7 +37,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
|
||||
parseHTML: element => {
|
||||
const colwidth = element.getAttribute('colwidth')
|
||||
const value = colwidth
|
||||
? [parseInt(colwidth, 10)]
|
||||
? colwidth.split(',').map(width => parseInt(width, 10))
|
||||
: null
|
||||
|
||||
return value
|
||||
|
@ -1,41 +1,52 @@
|
||||
// @ts-nocheck
|
||||
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { NodeView } from '@tiptap/pm/view'
|
||||
|
||||
import { getColStyleDeclaration } from './utilities/colStyle.js'
|
||||
|
||||
export function updateColumns(
|
||||
node: ProseMirrorNode,
|
||||
colgroup: Element,
|
||||
table: Element,
|
||||
colgroup: HTMLTableColElement, // <colgroup> has the same prototype as <col>
|
||||
table: HTMLTableElement,
|
||||
cellMinWidth: number,
|
||||
overrideCol?: number,
|
||||
overrideValue?: any,
|
||||
overrideValue?: number,
|
||||
) {
|
||||
let totalWidth = 0
|
||||
let fixedWidth = true
|
||||
let nextDOM = colgroup.firstChild
|
||||
const row = node.firstChild
|
||||
|
||||
for (let i = 0, col = 0; i < row.childCount; i += 1) {
|
||||
const { colspan, colwidth } = row.child(i).attrs
|
||||
if (row !== null) {
|
||||
for (let i = 0, col = 0; i < row.childCount; i += 1) {
|
||||
const { colspan, colwidth } = row.child(i).attrs
|
||||
|
||||
for (let j = 0; j < colspan; j += 1, col += 1) {
|
||||
const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j]
|
||||
const cssWidth = hasWidth ? `${hasWidth}px` : ''
|
||||
for (let j = 0; j < colspan; j += 1, col += 1) {
|
||||
const hasWidth = overrideCol === col ? overrideValue : (colwidth && colwidth[j]) as number | undefined
|
||||
const cssWidth = hasWidth ? `${hasWidth}px` : ''
|
||||
|
||||
totalWidth += hasWidth || cellMinWidth
|
||||
totalWidth += hasWidth || cellMinWidth
|
||||
|
||||
if (!hasWidth) {
|
||||
fixedWidth = false
|
||||
}
|
||||
|
||||
if (!nextDOM) {
|
||||
colgroup.appendChild(document.createElement('col')).style.width = cssWidth
|
||||
} else {
|
||||
if (nextDOM.style.width !== cssWidth) {
|
||||
nextDOM.style.width = cssWidth
|
||||
if (!hasWidth) {
|
||||
fixedWidth = false
|
||||
}
|
||||
|
||||
nextDOM = nextDOM.nextSibling
|
||||
if (!nextDOM) {
|
||||
const colElement = document.createElement('col')
|
||||
|
||||
const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth)
|
||||
|
||||
colElement.style.setProperty(propertyKey, propertyValue)
|
||||
|
||||
colgroup.appendChild(colElement)
|
||||
} else {
|
||||
if ((nextDOM as HTMLTableColElement).style.width !== cssWidth) {
|
||||
const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth);
|
||||
|
||||
(nextDOM as HTMLTableColElement).style.setProperty(propertyKey, propertyValue)
|
||||
}
|
||||
|
||||
nextDOM = nextDOM.nextSibling
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,7 +54,7 @@ export function updateColumns(
|
||||
while (nextDOM) {
|
||||
const after = nextDOM.nextSibling
|
||||
|
||||
nextDOM.parentNode.removeChild(nextDOM)
|
||||
nextDOM.parentNode?.removeChild(nextDOM)
|
||||
nextDOM = after
|
||||
}
|
||||
|
||||
@ -61,13 +72,13 @@ export class TableView implements NodeView {
|
||||
|
||||
cellMinWidth: number
|
||||
|
||||
dom: Element
|
||||
dom: HTMLDivElement
|
||||
|
||||
table: Element
|
||||
table: HTMLTableElement
|
||||
|
||||
colgroup: Element
|
||||
colgroup: HTMLTableColElement
|
||||
|
||||
contentDOM: Element
|
||||
contentDOM: HTMLTableSectionElement
|
||||
|
||||
constructor(node: ProseMirrorNode, cellMinWidth: number) {
|
||||
this.node = node
|
||||
|
10
packages/extension-table/src/utilities/colStyle.ts
Normal file
10
packages/extension-table/src/utilities/colStyle.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export function getColStyleDeclaration(minWidth: number, width: number | undefined): [string, string] {
|
||||
if (width) {
|
||||
// apply the stored width unless it is below the configured minimum cell width
|
||||
return ['width', `${Math.max(width, minWidth)}px`]
|
||||
}
|
||||
|
||||
// set the minimum with on the column if it has no stored width
|
||||
return ['min-width', `${minWidth}px`]
|
||||
|
||||
}
|
@ -1,5 +1,13 @@
|
||||
import { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
|
||||
import { getColStyleDeclaration } from './colStyle.js'
|
||||
|
||||
export type ColGroup = {
|
||||
colgroup: DOMOutputSpec
|
||||
tableWidth: string
|
||||
tableMinWidth: string
|
||||
} | Record<string, never>;
|
||||
|
||||
/**
|
||||
* Creates a colgroup element for a table node in ProseMirror.
|
||||
*
|
||||
@ -9,12 +17,22 @@ import { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
* @param overrideValue - (Optional) The width value to use for the overridden column.
|
||||
* @returns An object containing the colgroup element, the total width of the table, and the minimum width of the table.
|
||||
*/
|
||||
export function createColGroup(
|
||||
node: ProseMirrorNode,
|
||||
cellMinWidth: number,
|
||||
): ColGroup
|
||||
export function createColGroup(
|
||||
node: ProseMirrorNode,
|
||||
cellMinWidth: number,
|
||||
overrideCol: number,
|
||||
overrideValue: number,
|
||||
): ColGroup
|
||||
export function createColGroup(
|
||||
node: ProseMirrorNode,
|
||||
cellMinWidth: number,
|
||||
overrideCol?: number,
|
||||
overrideValue?: any,
|
||||
) {
|
||||
overrideValue?: number,
|
||||
): ColGroup {
|
||||
let totalWidth = 0
|
||||
let fixedWidth = true
|
||||
const cols: DOMOutputSpec[] = []
|
||||
@ -28,8 +46,7 @@ export function createColGroup(
|
||||
const { colspan, colwidth } = row.child(i).attrs
|
||||
|
||||
for (let j = 0; j < colspan; j += 1, col += 1) {
|
||||
const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j]
|
||||
const cssWidth = hasWidth ? `${hasWidth}px` : ''
|
||||
const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j] as number | undefined
|
||||
|
||||
totalWidth += hasWidth || cellMinWidth
|
||||
|
||||
@ -37,7 +54,12 @@ export function createColGroup(
|
||||
fixedWidth = false
|
||||
}
|
||||
|
||||
cols.push(['col', cssWidth ? { style: `width: ${cssWidth}` } : {}])
|
||||
const [property, value] = getColStyleDeclaration(cellMinWidth, hasWidth)
|
||||
|
||||
cols.push([
|
||||
'col',
|
||||
{ style: `${property}: ${value}` },
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -143,7 +143,7 @@
|
||||
"prosemirror-tables": "^1.4.0",
|
||||
"prosemirror-trailing-node": "^3.0.0",
|
||||
"prosemirror-transform": "^1.10.0",
|
||||
"prosemirror-view": "^1.33.10"
|
||||
"prosemirror-view": "^1.34.3"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -198,7 +198,10 @@ class EditorInstanceManager {
|
||||
if (this.editor && !this.editor.isDestroyed && deps.length === 0) {
|
||||
// if the editor does exist & deps are empty, we don't need to re-initialize the editor
|
||||
// we can fast-path to update the editor options on the existing instance
|
||||
this.editor.setOptions(this.options.current)
|
||||
this.editor.setOptions({
|
||||
...this.options.current,
|
||||
editable: this.editor.isEditable,
|
||||
})
|
||||
} else {
|
||||
// When the editor:
|
||||
// - does not yet exist
|
||||
|
@ -59,6 +59,7 @@ export const EditorContent = defineComponent({
|
||||
|
||||
editor.createNodeViews()
|
||||
})
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
@ -69,27 +70,8 @@ export const EditorContent = defineComponent({
|
||||
return
|
||||
}
|
||||
|
||||
// destroy nodeviews before vue removes dom element
|
||||
if (!editor.isDestroyed) {
|
||||
editor.view.setProps({
|
||||
nodeViews: {},
|
||||
})
|
||||
}
|
||||
|
||||
editor.contentComponent = null
|
||||
editor.appContext = null
|
||||
|
||||
if (!editor.options.element.firstChild) {
|
||||
return
|
||||
}
|
||||
|
||||
const newElement = document.createElement('div')
|
||||
|
||||
newElement.append(...editor.options.element.childNodes)
|
||||
|
||||
editor.setOptions({
|
||||
element: newElement,
|
||||
})
|
||||
})
|
||||
|
||||
return { rootEl }
|
||||
|
143
tests/cypress/integration/core/getMarkRange.spec.ts
Normal file
143
tests/cypress/integration/core/getMarkRange.spec.ts
Normal file
@ -0,0 +1,143 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
import {
|
||||
getMarkRange,
|
||||
getSchemaByResolvedExtensions,
|
||||
} from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { Node } from '@tiptap/pm/model'
|
||||
|
||||
describe('getMarkRange', () => {
|
||||
const document = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'This is a ' },
|
||||
{ type: 'text', text: 'linked', marks: [{ type: 'link', attrs: { href: 'https://tiptap.dev' } }] },
|
||||
{ type: 'text', text: ' text.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const schema = getSchemaByResolvedExtensions([
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Link.configure({ openOnClick: false }),
|
||||
])
|
||||
|
||||
it('gets the correct range for a position inside the mark', () => {
|
||||
const doc = Node.fromJSON(schema, document)
|
||||
const $pos = doc.resolve(14)
|
||||
const range = getMarkRange($pos, schema.marks.link)
|
||||
|
||||
expect(range).to.deep.eq({
|
||||
from: 11,
|
||||
to: 17,
|
||||
})
|
||||
})
|
||||
|
||||
it('gets the correct range for a position at the start of the mark', () => {
|
||||
const doc = Node.fromJSON(schema, document)
|
||||
const $pos = doc.resolve(11)
|
||||
const range = getMarkRange($pos, schema.marks.link)
|
||||
|
||||
expect(range).to.deep.eq({
|
||||
from: 11,
|
||||
to: 17,
|
||||
})
|
||||
})
|
||||
|
||||
it('gets the correct range for a position at the end of the mark', () => {
|
||||
const doc = Node.fromJSON(schema, document)
|
||||
const $pos = doc.resolve(17)
|
||||
const range = getMarkRange($pos, schema.marks.link)
|
||||
|
||||
expect(range).to.deep.eq({
|
||||
from: 11,
|
||||
to: 17,
|
||||
})
|
||||
})
|
||||
|
||||
it('gets undefined if a mark is not found', () => {
|
||||
const doc = Node.fromJSON(schema, document)
|
||||
const $pos = doc.resolve(6)
|
||||
const range = getMarkRange($pos, schema.marks.link)
|
||||
|
||||
expect(range).to.eq(undefined)
|
||||
})
|
||||
|
||||
it('doesnt cross node boundaries on backward check', () => {
|
||||
const testDocument = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'This is a text with a ' },
|
||||
{ type: 'text', text: 'link.', marks: [{ type: 'link', attrs: { href: 'https://tiptap.dev' } }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'This is a text without a link.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const doc = Node.fromJSON(schema, testDocument)
|
||||
const $pos = doc.resolve(28)
|
||||
const range = getMarkRange($pos, schema.marks.link)
|
||||
|
||||
expect(range).to.deep.eq({
|
||||
from: 23,
|
||||
to: 28,
|
||||
})
|
||||
|
||||
const nextRange = getMarkRange(doc.resolve(30), schema.marks.link)
|
||||
|
||||
expect(nextRange).to.eq(undefined)
|
||||
})
|
||||
|
||||
it('doesnt cross node boundaries on forward check', () => {
|
||||
const testDocument = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'This is a text without a link.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'A link', marks: [{ type: 'link', attrs: { href: 'https://tiptap.dev' } }] },
|
||||
{ type: 'text', text: ' is at the start of this paragraph.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const doc = Node.fromJSON(schema, testDocument)
|
||||
|
||||
const range = getMarkRange(doc.resolve(32), schema.marks.link)
|
||||
|
||||
expect(range).to.eq(undefined)
|
||||
|
||||
const $pos = doc.resolve(33)
|
||||
const nextRange = getMarkRange($pos, schema.marks.link)
|
||||
|
||||
expect(nextRange).to.deep.eq({
|
||||
from: 33,
|
||||
to: 39,
|
||||
})
|
||||
})
|
||||
})
|
@ -134,16 +134,23 @@ describe('onContentError', () => {
|
||||
|
||||
const editor = new Editor({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, Extension.create({ name: 'collaboration' })],
|
||||
extensions: [Document, Paragraph, Text, Extension.create({
|
||||
name: 'collaboration',
|
||||
addStorage() {
|
||||
return {
|
||||
isDisabled: false,
|
||||
}
|
||||
},
|
||||
})],
|
||||
enableContentCheck: true,
|
||||
onContentError: args => {
|
||||
args.disableCollaboration()
|
||||
expect(args.editor.extensionManager.extensions.find(extension => extension.name === 'collaboration')).to.eq(undefined)
|
||||
expect(args.editor.storage.collaboration.isDisabled).to.eq(true)
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getText()).to.eq('')
|
||||
expect(editor.extensionManager.extensions.find(extension => extension.name === 'collaboration')).to.eq(undefined)
|
||||
expect(editor.storage.collaboration.isDisabled).to.eq(true)
|
||||
})
|
||||
|
||||
it('does not remove the collaboration extension when has valid content (when enableContentCheck = true)', () => {
|
||||
@ -164,14 +171,22 @@ describe('onContentError', () => {
|
||||
|
||||
const editor = new Editor({
|
||||
content: json,
|
||||
extensions: [Document, Paragraph, Text, Extension.create({ name: 'collaboration' })],
|
||||
extensions: [Document, Paragraph, Text, Extension.create({
|
||||
name: 'collaboration',
|
||||
addStorage() {
|
||||
return {
|
||||
isDisabled: false,
|
||||
}
|
||||
},
|
||||
})],
|
||||
enableContentCheck: true,
|
||||
onContentError: () => {
|
||||
// Should not be called, so we fail the test
|
||||
expect(true).to.eq(false)
|
||||
},
|
||||
})
|
||||
|
||||
expect(editor.getText()).to.eq('Example Text')
|
||||
expect(editor.extensionManager.extensions.find(extension => extension.name === 'collaboration')).to.not.eq(undefined)
|
||||
expect(editor.storage.collaboration.isDisabled).to.eq(false)
|
||||
})
|
||||
})
|
||||
|
99
tests/cypress/integration/extensions/tableCell.spec.ts
Normal file
99
tests/cypress/integration/extensions/tableCell.spec.ts
Normal file
@ -0,0 +1,99 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import { Table } from '@tiptap/extension-table'
|
||||
import { TableCell } from '@tiptap/extension-table-cell'
|
||||
import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import { TableRow } from '@tiptap/extension-table-row'
|
||||
import Text from '@tiptap/extension-text'
|
||||
|
||||
describe('extension table cell', () => {
|
||||
const editorElClass = 'tiptap'
|
||||
let editor: Editor | null = null
|
||||
|
||||
const createEditorEl = () => {
|
||||
const editorEl = document.createElement('div')
|
||||
|
||||
editorEl.classList.add(editorElClass)
|
||||
document.body.appendChild(editorEl)
|
||||
return editorEl
|
||||
}
|
||||
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
|
||||
|
||||
it('should start with a Table', () => {
|
||||
const content = '<table style="width:100%"><tr><td>Firstname</td><td>Lastname</td><td>Age</td></tr><tr><td>Jill</td><td>Smith</td><td>50</td></tr><tr><td>Eve</td><td>Jackson</td><td>94</td></tr><tr><td>John</td><td>Doe</td><td>80</td></tr></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).to.include('Jackson')
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('should parse a single colWidth', () => {
|
||||
const content = '<table><tbody><tr><td colwidth="200">Name</td><td>Description</td></tr><tr><td>Cyndi Lauper</td><td>Singer</td><td>Songwriter</td><td>Actress</td></tr><tr><td>Marie Curie</td><td>Scientist</td><td>Chemist</td><td>Physicist</td></tr><tr><td>Indira Gandhi</td><td>Prime minister</td><td colspan="2">Politician</td></tr></tbody></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
expect(editor.getJSON().content[0].content[0].content[0].attrs.colwidth[0]).to.eq(200)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('should parse multiple colWidths', () => {
|
||||
const content = '<table><tbody><tr><td colwidth="200">Name</td><td colspan="3" colwidth="150,100">Description</td></tr><tr><td>Cyndi Lauper</td><td>Singer</td><td>Songwriter</td><td>Actress</td></tr><tr><td>Marie Curie</td><td>Scientist</td><td>Chemist</td><td>Physicist</td></tr><tr><td>Indira Gandhi</td><td>Prime minister</td><td colspan="2">Politician</td></tr></tbody></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
expect(editor.getJSON().content[0].content[0].content[1].attrs.colwidth).deep.equal([150, 100])
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
99
tests/cypress/integration/extensions/tableHeader.spec.ts
Normal file
99
tests/cypress/integration/extensions/tableHeader.spec.ts
Normal file
@ -0,0 +1,99 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import { Table } from '@tiptap/extension-table'
|
||||
import { TableCell } from '@tiptap/extension-table-cell'
|
||||
import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import { TableRow } from '@tiptap/extension-table-row'
|
||||
import Text from '@tiptap/extension-text'
|
||||
|
||||
describe('extension table header', () => {
|
||||
const editorElClass = 'tiptap'
|
||||
let editor: Editor | null = null
|
||||
|
||||
const createEditorEl = () => {
|
||||
const editorEl = document.createElement('div')
|
||||
|
||||
editorEl.classList.add(editorElClass)
|
||||
document.body.appendChild(editorEl)
|
||||
return editorEl
|
||||
}
|
||||
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
|
||||
|
||||
it('should start with a Table', () => {
|
||||
const content = '<table style="width:100%"><tr><th>Firstname</th><th>Lastname</th><th>Age</th></tr><tr><td>Jill</td><td>Smith</td><td>50</td></tr><tr><td>Eve</td><td>Jackson</td><td>94</td></tr><tr><td>John</td><td>Doe</td><td>80</td></tr></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
expect(editor.getHTML()).to.include('Jackson')
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('should parse a single colWidth', () => {
|
||||
const content = '<table><tbody><tr><th colwidth="200">Name</th><th>Description</th></tr><tr><td>Cyndi Lauper</td><td>Singer</td><td>Songwriter</td><td>Actress</td></tr><tr><td>Marie Curie</td><td>Scientist</td><td>Chemist</td><td>Physicist</td></tr><tr><td>Indira Gandhi</td><td>Prime minister</td><td colspan="2">Politician</td></tr></tbody></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
expect(editor.getJSON().content[0].content[0].content[0].attrs.colwidth[0]).to.eq(200)
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
|
||||
it('should parse multiple colWidths', () => {
|
||||
const content = '<table><tbody><tr><th colwidth="200">Name</th><th colspan="3" colwidth="150,100">Description</th></tr><tr><td>Cyndi Lauper</td><td>Singer</td><td>Songwriter</td><td>Actress</td></tr><tr><td>Marie Curie</td><td>Scientist</td><td>Chemist</td><td>Physicist</td></tr><tr><td>Indira Gandhi</td><td>Prime minister</td><td colspan="2">Politician</td></tr></tbody></table>'
|
||||
|
||||
editor = new Editor({
|
||||
element: createEditorEl(),
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
})
|
||||
|
||||
expect(editor.getJSON().content[0].content[0].content[1].attrs.colwidth).deep.equal([150, 100])
|
||||
|
||||
editor?.destroy()
|
||||
getEditorEl()?.remove()
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue
Block a user