Merge branch 'develop' into next

This commit is contained in:
Nick the Sick 2024-11-01 16:30:54 +01:00
commit ad7ea1a072
No known key found for this signature in database
GPG Key ID: F575992F156E5BCC
76 changed files with 4086 additions and 2951 deletions

View File

@ -0,0 +1,5 @@
---
"@tiptap/core": patch
---
preserve existing node attributes when running setNode

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

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

View 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

View File

@ -0,0 +1,5 @@
---
"@tiptap/extension-mention": patch
---
add zero-width space to resolve cursor selection issue

View File

@ -0,0 +1,5 @@
---
"@tiptap/extension-table": patch
---
enforce cellMinWidth even on column not resized by the user, fixes #5435

View File

@ -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: |

View File

@ -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: |

View File

@ -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,

View File

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

View File

@ -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

View File

@ -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>
)

View File

@ -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()

View File

@ -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>

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

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

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

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

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

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

View 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>Lets finish with a boring paragraph.</p>
`
export default () => {
return <EditorProvider extensions={extensions} content={content}></EditorProvider>
}

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

View File

@ -29,7 +29,6 @@
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,

View File

@ -10,5 +10,8 @@
box-decoration-break: clone;
color: var(--purple);
padding: 0.1rem 0.3rem;
&::after {
content: "\200B";
}
}
}

View File

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

View File

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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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 dont 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({

View File

@ -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
/**

View File

@ -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({

View File

@ -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
/**

View File

@ -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
/**

View File

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

View File

@ -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({

View File

@ -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;
};
}
}

View File

@ -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 {

View File

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

View File

@ -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()
)

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

View File

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

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

View File

@ -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 } = {},

View File

@ -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: {},

View File

@ -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) {

View File

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

View File

@ -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

View File

@ -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: {
/**

View File

@ -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,

View File

@ -9,7 +9,7 @@ import { callOrReturn } from '../utilities/callOrReturn.js'
* matched text is typed into it. When using a regular expresion youll
* 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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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,

View File

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

View File

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

View File

@ -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>;
}

View File

@ -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,

View File

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

View File

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

View File

@ -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',
},

View File

@ -77,6 +77,8 @@ export const MentionPluginKey = new PluginKey('mention')
export const Mention = Node.create<MentionOptions>({
name: 'mention',
priority: 101,
addOptions() {
return {
HTMLAttributes: {},

View File

@ -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

View File

@ -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

View File

@ -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

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

View File

@ -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}` },
])
}
}

View File

@ -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",

View File

@ -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

View File

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

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

View File

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

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

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